initial
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.8] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.7] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.6] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [0.3.5] - 2026-02-10
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.4] - 2025-09-01
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.3] - 2025-08-18
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.2] - 2025-08-04
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.1] - 2025-07-21
|
||||
### Changed
|
||||
- Scanner: Revert changes to returned parsed attributes. [#44365]
|
||||
|
||||
## [0.3.0] - 2025-07-14
|
||||
### Added
|
||||
- Add Block_Scanner class as a replacement to Block_Delimiter. [#44158]
|
||||
|
||||
## [0.2.1] - 2025-06-23
|
||||
### Changed
|
||||
- Performance improvement: Extract anonymous function into static method. [#43984]
|
||||
|
||||
## [0.2.0] - 2025-06-09
|
||||
### Changed
|
||||
- Update PHPCS and Phan configuration to be more permissive, and update main class accordingly. [#43683]
|
||||
|
||||
## 0.1.0 - 2025-05-28
|
||||
### Added
|
||||
- Initial release.
|
||||
|
||||
[0.3.8]: https://github.com/Automattic/block-delimiter/compare/v0.3.7...v0.3.8
|
||||
[0.3.7]: https://github.com/Automattic/block-delimiter/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/Automattic/block-delimiter/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Automattic/block-delimiter/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Automattic/block-delimiter/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Automattic/block-delimiter/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Automattic/block-delimiter/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/block-delimiter/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/block-delimiter/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://github.com/Automattic/block-delimiter/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/block-delimiter/compare/v0.1.0...v0.2.0
|
||||
@@ -0,0 +1,900 @@
|
||||
<?php
|
||||
/**
|
||||
* Efficiently working with block structure.
|
||||
*
|
||||
* @package automattic/block-delimiter
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Class for efficiently working with block structure.
|
||||
*
|
||||
* This class follows design values of the HTML API:
|
||||
* - minimize allocations and strive for zero memory overhead
|
||||
* - make costs explicit; pay only for what you need
|
||||
* - follow a streaming, re-entrant design for pausing and aborting
|
||||
*
|
||||
* For usage, jump straight to {@see static::next_delimiter}.
|
||||
*/
|
||||
class Block_Delimiter {
|
||||
/**
|
||||
* Indicates if the last operation failed, otherwise
|
||||
* will be `null` for success.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private static $last_error = self::UNINITIALIZED;
|
||||
|
||||
/**
|
||||
* Indicates failures from decoding JSON attributes.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
/**
|
||||
* Holds a reference to the original source text from which to
|
||||
* extract the parsed spans of the delimiter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $source_text;
|
||||
|
||||
/**
|
||||
* Byte offset into source text where entire delimiter begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_at;
|
||||
|
||||
/**
|
||||
* Byte length of full span of delimiter.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_length;
|
||||
|
||||
/**
|
||||
* Byte offset where namespace span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_at;
|
||||
|
||||
/**
|
||||
* Byte length of namespace span, or `0` if implicitly in the "core" namespace.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_length;
|
||||
|
||||
/**
|
||||
* Byte offset where block name span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_at;
|
||||
|
||||
/**
|
||||
* Byte length of block name span.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_length;
|
||||
|
||||
/**
|
||||
* Whether the delimiter contains the block self-closing flag.
|
||||
*
|
||||
* This may be erroneous if present within a block closer,
|
||||
* therefore the {@see self::has_void_flag} can be used by
|
||||
* calling code to perform appropriate error-handling.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $has_void_flag = false;
|
||||
|
||||
/**
|
||||
* Byte offset where JSON attributes span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_at;
|
||||
|
||||
/**
|
||||
* Byte length of JSON attributes span, or `0` if none are present.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_length;
|
||||
|
||||
/**
|
||||
* Indicates what kind of block comment delimiter this represents.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER` If the delimiter is opening a block.
|
||||
* - `static::CLOSER` If the delimiter is closing an open block.
|
||||
* - `static::VOID` If the delimiter represents a void block with no inner content.
|
||||
*
|
||||
* If a parsed comment delimiter contains both the closing and the void
|
||||
* flags then it will be interpreted as a void block to match the behavior
|
||||
* of the official block parser, however, this is a mistake and probably
|
||||
* the block ought to close an open block of the same name, if one is open.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $type;
|
||||
|
||||
/**
|
||||
* Finds the next block delimiter in a text document and returns a parsed
|
||||
* block delimiter info record if it parses, otherwise returns `null`.
|
||||
*
|
||||
* Block comment delimiters must be valid HTML comments and may contain JSON.
|
||||
* This search does not determine, however, if the JSON is valid.
|
||||
*
|
||||
* Example delimiters:
|
||||
*
|
||||
* `<!-- wp:paragraph {"dropCap": true} -->`
|
||||
* `<!-- wp:separator /-->`
|
||||
* `<!-- /wp:paragraph -->`
|
||||
*
|
||||
* In the case that a block comment delimiter contains both the void indicator and
|
||||
* also the closing indicator, it will be treated as a void block.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Find all image block opening delimiters.
|
||||
* $at = 0;
|
||||
* $end = strlen( $html );
|
||||
* $images = array();
|
||||
* while ( $at < $end ) {
|
||||
* $delimiter = Block_Delimiter::next_delimiter( $html, $at, $next_at, $next_length );
|
||||
* if ( ! isset( $delimiter ) ) {
|
||||
* break;
|
||||
* }
|
||||
*
|
||||
* if (
|
||||
* Block_Delimiter::OPENER === $delimiter->get_delimiter_type() &&
|
||||
* $delimiter->is_block_type( 'core/image' )
|
||||
* ) {
|
||||
* $images[] = $delimiter;
|
||||
* }
|
||||
*
|
||||
* $at = $next_at + $next_length;
|
||||
* }
|
||||
*
|
||||
* @param string $text Input document possibly containing block comment delimiters.
|
||||
* @param int $starting_byte_offset Where in the input document to begin searching.
|
||||
* @param int|null $match_byte_offset Optional. When provided, will be set to the byte offset in
|
||||
* the input document where the delimiter was found, if one
|
||||
* is found, otherwise not set.
|
||||
* @param int|null $match_byte_length Optional. When provided, will be set to the byte length of
|
||||
* the matched delimiter if one is found, otherwise not set.
|
||||
* @return Block_Delimiter|null Parsed block delimiter info record if found, otherwise `null`.
|
||||
*/
|
||||
public static function next_delimiter( string $text, int $starting_byte_offset, ?int &$match_byte_offset = null, ?int &$match_byte_length = null ): ?Block_Delimiter {
|
||||
$end = strlen( $text );
|
||||
$at = $starting_byte_offset;
|
||||
$delimiter = null;
|
||||
static::$last_error = null;
|
||||
|
||||
while ( $at < $end ) {
|
||||
/*
|
||||
* Find the next possible opening.
|
||||
*
|
||||
* This follows the behavior in the official block parser, which treats a post
|
||||
* as a list of blocks with nested HTML. If HTML comment syntax appears within
|
||||
* an HTML attribute value, SCRIPT or STYLE element, or in other select places,
|
||||
* which it can do inside of HTML, then the block parsing may break.
|
||||
*
|
||||
* For a more robust parse scan through the document with the HTML API. In
|
||||
* practice, this has not been a problem in the entire history of blocks.
|
||||
*/
|
||||
$comment_opening_at = strpos( $text, '<!--', $at );
|
||||
if ( false === $comment_opening_at ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$opening_whitespace_at = $comment_opening_at + 4;
|
||||
$opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
|
||||
if ( 0 === $opening_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
|
||||
if ( $wp_prefix_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_closer = false;
|
||||
if ( '/' === $text[ $wp_prefix_at ] ) {
|
||||
$has_closer = true;
|
||||
++$wp_prefix_at;
|
||||
}
|
||||
|
||||
if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_at = $wp_prefix_at + 3;
|
||||
if ( $namespace_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$start_of_namespace = $text[ $namespace_at ];
|
||||
|
||||
// The namespace must start with a-z.
|
||||
if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 );
|
||||
$separator_at = $namespace_at + $namespace_length;
|
||||
if ( $separator_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
$has_separator = '/' === $text[ $separator_at ];
|
||||
if ( $has_separator ) {
|
||||
$name_at = $separator_at + 1;
|
||||
$start_of_name = $text[ $name_at ];
|
||||
if ( 'a' > $start_of_name || 'z' < $start_of_name ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 );
|
||||
} else {
|
||||
$name_at = $namespace_at;
|
||||
$name_length = $namespace_length;
|
||||
$namespace_length = 0;
|
||||
}
|
||||
|
||||
$after_name_whitespace_at = $name_at + $name_length;
|
||||
$after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
|
||||
if ( 0 === $after_name_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_at = $after_name_whitespace_at + $after_name_whitespace_length;
|
||||
if ( $json_at >= $end ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
$has_json = '{' === $text[ $json_at ];
|
||||
$json_length = 0;
|
||||
|
||||
/*
|
||||
* For the final span of the delimiter it's most efficient to find the end
|
||||
* of the HTML comment and work backwards. This prevents complicated parsing
|
||||
* inside the JSON span, which cannot contain the HTML comment terminator.
|
||||
*
|
||||
* This also matches the behavior in the official block parser, though it
|
||||
* allows for matching invalid JSON content.
|
||||
*/
|
||||
$comment_closing_at = strpos( $text, '-->', $json_at );
|
||||
if ( false === $comment_closing_at ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* It looks like this logic leaves an error in here, when the position
|
||||
* overlaps the JSON or block name. However, for neither of those is it
|
||||
* possible to parse a valid block if that last overlapping character
|
||||
* is the void flag. This, therefore, will be valid regardless of how
|
||||
* the rest of the comment delimiter is written.
|
||||
*/
|
||||
if ( '/' === $text[ $comment_closing_at - 1 ] ) {
|
||||
$has_void_flag = true;
|
||||
$void_flag_length = 1;
|
||||
} else {
|
||||
$has_void_flag = false;
|
||||
$void_flag_length = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* If there's no JSON, then the span of text after the name
|
||||
* until the comment closing must be completely whitespace.
|
||||
*/
|
||||
if ( ! $has_json ) {
|
||||
$max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length;
|
||||
|
||||
// This shouldn't be possible, but it can't be allowed regardless.
|
||||
if ( $max_whitespace_length < 0 ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length );
|
||||
if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$delimiter = new static();
|
||||
break;
|
||||
}
|
||||
|
||||
// There's no JSON, so attempt to find its boundary.
|
||||
$after_json_whitespace_length = 0;
|
||||
for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) {
|
||||
$char = $text[ $char_at ];
|
||||
|
||||
switch ( $char ) {
|
||||
case ' ':
|
||||
case "\t":
|
||||
case "\f":
|
||||
case "\r":
|
||||
case "\n":
|
||||
++$after_json_whitespace_length;
|
||||
continue 2;
|
||||
|
||||
case '}':
|
||||
$json_length = $char_at - $json_at + 1;
|
||||
break 2;
|
||||
|
||||
default:
|
||||
++$at;
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
|
||||
if ( 0 === $json_length || 0 === $after_json_whitespace_length ) {
|
||||
$at = self::find_html_comment_end( $text, $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$delimiter = new static();
|
||||
break;
|
||||
}
|
||||
|
||||
if ( null === $delimiter ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$delimiter->source_text = $text;
|
||||
|
||||
$delimiter->delimiter_at = $comment_opening_at;
|
||||
$delimiter->delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
|
||||
|
||||
$delimiter->namespace_at = $namespace_at;
|
||||
$delimiter->namespace_length = $namespace_length;
|
||||
|
||||
$delimiter->name_at = $name_at;
|
||||
$delimiter->name_length = $name_length;
|
||||
|
||||
$delimiter->json_at = $json_at;
|
||||
$delimiter->json_length = $json_length;
|
||||
|
||||
$delimiter->type = $has_closer
|
||||
? static::CLOSER
|
||||
: ( $has_void_flag ? static::VOID : static::OPENER );
|
||||
|
||||
$delimiter->has_void_flag = $has_void_flag;
|
||||
|
||||
$match_byte_offset = $delimiter->delimiter_at;
|
||||
$match_byte_length = $delimiter->delimiter_length;
|
||||
|
||||
return $delimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function to traverse block delimiters in a text and also
|
||||
* yield the position information for the delimiter as it scans.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* foreach ( Block_Delimiter::scan_delimiters( $post_content ) as $where => $delimiter ) {
|
||||
* echo "Found a {$delimiter->allocate_and_return_block_type()} at {$where[0]} (length is {$where[1]} bytes)\n";
|
||||
* }
|
||||
*
|
||||
* @param string $text Document potentially containing blocks.
|
||||
* @param ?string $freeform_blocks Optional. 'visit' to visit virtual block delimiters for freeform content.
|
||||
* @return \Generator Visits each block delimiter and provides [ at, length ] => delimiter.
|
||||
*/
|
||||
public static function scan_delimiters( string $text, ?string $freeform_blocks = 'skip' ): \Generator {
|
||||
$at = 0;
|
||||
$depth = 0;
|
||||
|
||||
/*
|
||||
* Although `phpcs` confidently asserts that `$match_at` and `$match_length`
|
||||
* are undefined, it is not aware enough to realize that they are set by the
|
||||
* call to `next_delimiter` and so it’s necessary to alter the code so it
|
||||
* doesn’t get confused and reject valid code.
|
||||
*/
|
||||
$match_at = 0;
|
||||
$match_length = 0;
|
||||
|
||||
while ( null !== ( $delimiter = self::next_delimiter( $text, $at, $match_at, $match_length ) ) ) {
|
||||
// Handle top-level text as freeform blocks
|
||||
if ( 0 === $depth && $match_at > $at && 'visit' === $freeform_blocks ) {
|
||||
list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $match_at - $at );
|
||||
|
||||
++$depth;
|
||||
yield [ $at, 0 ] => $text_opener;
|
||||
|
||||
--$depth;
|
||||
yield [ $match_at, 0 ] => $text_closer;
|
||||
}
|
||||
|
||||
$delimiter_type = $delimiter->get_delimiter_type();
|
||||
|
||||
switch ( $delimiter_type ) {
|
||||
case static::OPENER:
|
||||
case static::VOID:
|
||||
++$depth;
|
||||
break;
|
||||
|
||||
case static::CLOSER:
|
||||
--$depth;
|
||||
break;
|
||||
}
|
||||
|
||||
yield [ $match_at, $match_length ] => $delimiter;
|
||||
|
||||
if ( static::VOID === $delimiter_type ) {
|
||||
--$depth;
|
||||
}
|
||||
|
||||
$at = $match_at + $match_length;
|
||||
}
|
||||
|
||||
$end = strlen( $text );
|
||||
if ( 'visit' === $freeform_blocks && $at < $end ) {
|
||||
list( $text_opener, $text_closer ) = static::freeform_pair( $text, $at, $end - $at );
|
||||
|
||||
++$depth;
|
||||
yield [ $at, 0 ] => $text_opener;
|
||||
|
||||
--$depth;
|
||||
yield [ $end, 0 ] => $text_closer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor function.
|
||||
*/
|
||||
private function __construct() {
|
||||
// This is not to be called from the outside.
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the byte-offset after the ending character of an HTML comment,
|
||||
* assuming the proper starting byte offset.
|
||||
*
|
||||
* @since 0.2.1
|
||||
*
|
||||
* @param string $text Document in which to search for HTML comment end.
|
||||
* @param int $comment_starting_at Where the HTML comment started, the leading `<`.
|
||||
* @param int $search_end Last offset in which to search, for limiting search span.
|
||||
* @return int Offset after the current HTML comment ends, or `$end` if no end was found.
|
||||
*/
|
||||
private static function find_html_comment_end( string $text, int $comment_starting_at, int $search_end ): int {
|
||||
// Find span-of-dashes comments which look like `<!----->`.
|
||||
$span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 );
|
||||
if (
|
||||
$comment_starting_at + 2 + $span_of_dashes < $search_end &&
|
||||
'>' === $text[ $comment_starting_at + 2 + $span_of_dashes ]
|
||||
) {
|
||||
return $comment_starting_at + $span_of_dashes + 1;
|
||||
}
|
||||
|
||||
// Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`.
|
||||
$now_at = $comment_starting_at + 4;
|
||||
while ( $now_at < $search_end ) {
|
||||
$dashes_at = strpos( $text, '--', $now_at );
|
||||
if ( false === $dashes_at ) {
|
||||
static::$last_error = self::INCOMPLETE_INPUT;
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
$closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 );
|
||||
if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) {
|
||||
$closer_must_be_at++;
|
||||
}
|
||||
|
||||
if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) {
|
||||
return $closer_must_be_at + 1;
|
||||
}
|
||||
|
||||
$now_at++;
|
||||
}
|
||||
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pair of delimiters for freeform text content
|
||||
* since there are no delimiters in a document for them.
|
||||
*
|
||||
* @param string $text Source document.
|
||||
* @param int $at Where the text region starts (byte offset).
|
||||
* @param int $length How long the text region spans (byte count).
|
||||
* @return static[] Opening and closing block delimiters for the text region.
|
||||
*/
|
||||
private static function freeform_pair( string $text, int $at, int $length ): array {
|
||||
$opener = new static();
|
||||
$opener->source_text = $text;
|
||||
$opener->delimiter_at = $at;
|
||||
$opener->delimiter_length = 0;
|
||||
$opener->namespace_at = $at;
|
||||
$opener->namespace_length = 0;
|
||||
$opener->name_at = $at;
|
||||
$opener->name_length = 0;
|
||||
$opener->json_at = $at;
|
||||
$opener->json_length = 0;
|
||||
$opener->type = static::OPENER;
|
||||
$opener->has_void_flag = false;
|
||||
|
||||
$closer = clone $opener;
|
||||
$end_at = $at + $length;
|
||||
$closer->delimiter_at = $end_at;
|
||||
$closer->namespace_at = $end_at;
|
||||
$closer->name_at = $end_at;
|
||||
$closer->json_at = $end_at;
|
||||
$closer->type = static::CLOSER;
|
||||
|
||||
return [ $opener, $closer ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block comment delimiter
|
||||
* failed, if set, otherwise `null` if the last attempt succeeded.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_last_error() {
|
||||
return static::$last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block’s JSON attributes failed.
|
||||
*
|
||||
* @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc…
|
||||
*
|
||||
* @return int JSON_ERROR_ code from last attempt to parse block JSON attributes.
|
||||
*/
|
||||
public function get_last_json_error(): int {
|
||||
return $this->last_json_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring from the source text containing the delimiter
|
||||
* and releases the reference to the source text.
|
||||
*
|
||||
* Use this function when the delimiter is holding on to the source
|
||||
* text and preventing it from being freed by PHP. This function incurs
|
||||
* a string allocation; if the source text will be retained anyway then
|
||||
* there's no need to detach as that memory cannot be freed.
|
||||
*
|
||||
* This is a low-level function available for controlling the performance
|
||||
* of sensitive hot-paths. You probably don't need this.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* function first_block( $html ) {
|
||||
* return Block_Delimiter::next_delimiter( $really_long_html, 0 );
|
||||
* }
|
||||
*
|
||||
* $delimiter = first_block( $really_long_html_document );
|
||||
* // `$really_long_html_document` is still retained inside `$delimiter`, which could lead to a memory leak.
|
||||
*
|
||||
* $delimiter->allocate_and_detach_from_source_text();
|
||||
* // `$really_long_html_document` is no longer referenced, and its memory may be freed or used for something else.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function allocate_and_detach_from_source_text(): void {
|
||||
$this->source_text = substr( $this->source_text, $this->delimiter_at, $this->delimiter_length );
|
||||
|
||||
$byte_delta = $this->delimiter_at;
|
||||
|
||||
$this->delimiter_at -= $byte_delta;
|
||||
$this->namespace_at -= $byte_delta;
|
||||
$this->name_at -= $byte_delta;
|
||||
$this->json_at -= $byte_delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the block comment delimiter.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER`
|
||||
* - `static::CLOSER`
|
||||
* - `static::VOID`
|
||||
*
|
||||
* @return string type of the block comment delimiter.
|
||||
*/
|
||||
public function get_delimiter_type(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the delimiter contains the void flag.
|
||||
*
|
||||
* This should be avoided except in cases of handling errors with
|
||||
* block closers containing the void flag. For normative use,
|
||||
* {@see self::get_delimiter_type}.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_void_flag(): bool {
|
||||
return $this->has_void_flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the block delimiter represents a block of the given type.
|
||||
*
|
||||
* Since the "core" namespace may be implicit, it's allowable to pass
|
||||
* either the fully-qualified block type with namespace and block name
|
||||
* as well as the shorthand version only containing the block name, if
|
||||
* the desired block is in the "core" namespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $is_core_paragraph = $delimiter->is_block_type( 'paragraph' );
|
||||
* $is_core_paragraph = $delimiter->is_block_type( 'core/paragraph' );
|
||||
* $is_formula = $delimiter->is_block_type( 'math-block/formula' );
|
||||
*
|
||||
* @param string $block_type Block type name for the desired block.
|
||||
* E.g. "paragraph", "core/paragraph", "math-blocks/formula".
|
||||
* @return bool Whether this delimiter represents a block of the given type.
|
||||
*/
|
||||
public function is_block_type( string $block_type ): bool {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform' === $block_type || 'freeform' === $block_type;
|
||||
}
|
||||
|
||||
$slash_at = strpos( $block_type, '/' );
|
||||
if ( false === $slash_at ) {
|
||||
$namespace = 'core';
|
||||
$block_name = $block_type;
|
||||
} else {
|
||||
$namespace = substr( $block_type, 0, $slash_at );
|
||||
$block_name = substr( $block_type, $slash_at + 1 );
|
||||
}
|
||||
|
||||
// Only the 'core' namespace is allowed to be omitted.
|
||||
if ( 0 === $this->namespace_length && 'core' !== $namespace ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If given an explicit namespace, they must match.
|
||||
if (
|
||||
0 !== $this->namespace_length && (
|
||||
strlen( $namespace ) !== $this->namespace_length ||
|
||||
0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length )
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The block name must match.
|
||||
return (
|
||||
strlen( $block_name ) === $this->name_length &&
|
||||
0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring for the block type and returns the
|
||||
* fully-qualified name, including the namespace.
|
||||
*
|
||||
* This function allocates a substring for the given block type. This
|
||||
* allocation will be small and likely fine in most cases, but it's
|
||||
* preferable to call {@link static::is_block_type} if only needing
|
||||
* to know whether the delimiter is for a given block type, as that
|
||||
* function is more efficient for this purpose and avoids the allocation.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* 'core/paragraph' = $delimiter->allocate_and_return_block_type();
|
||||
*
|
||||
* @return string Fully-qualified block namespace and type, e.g. "core/paragraph".
|
||||
*/
|
||||
public function allocate_and_return_block_type(): string {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform';
|
||||
}
|
||||
|
||||
// This is implicitly in the "core" namespace.
|
||||
if ( 0 === $this->namespace_length ) {
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
return "core/{$block_name}";
|
||||
}
|
||||
|
||||
return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lazy wrapper around the block attributes, which can be used
|
||||
* for efficiently interacting with the JSON attributes.
|
||||
*
|
||||
* @throws Exception This function is not yet implemented.
|
||||
*
|
||||
* @todo Create a lazy JSON wrapper so specific attributes can be
|
||||
* efficiently queried without parsing everything and loading
|
||||
* the entire object into memory.
|
||||
* @todo After realistic benchmarking, see if JsonStreamingParser\Parser
|
||||
* could be used — it would need to be fast enough for the reduction
|
||||
* in memory use to be worth it, compared to {@see \json_decode}.
|
||||
*
|
||||
* @see \JsonStreamingParser\Parser
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function get_attributes(): void {
|
||||
throw new Exception( 'Lazy attribute parsing not yet supported' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse and return the entire JSON attributes from the delimiter,
|
||||
* allocating memory and processing the JSON span in the process.
|
||||
*
|
||||
* This does not return any parsed attributes for a closing block delimiter
|
||||
* even if there is a span of JSON content; this JSON is a parsing error.
|
||||
*
|
||||
* Consider calling {@link static::get_attributes} instead if it's not
|
||||
* necessary to read all the attributes at the same time, as that provides
|
||||
* a more efficient mechanism for typical use cases.
|
||||
*
|
||||
* Since the JSON span inside the comment delimiter may not be valid JSON,
|
||||
* this function will return `null` if it cannot parse the span and set the
|
||||
* {@see static::get_last_json_error} to the appropriate JSON_ERROR_ constant.
|
||||
*
|
||||
* If the delimiter contains no JSON span, it will also return `null`,
|
||||
* but the last error will be set to {@see JSON_ERROR_NONE}.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
|
||||
* $memory_hungry_and_slow_attributes = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_NONE = $delimiter->get_last_json_error();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator {} /-->', 0 );
|
||||
* array() === $delimiter->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:separator /-->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $delimiter = Block_Delimiter::next_delimiter( '<!-- wp:image {"url} -->', 0 );
|
||||
* null = $delimiter->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_CTRL_CHAR = $delimiter->get_last_json_error();
|
||||
*
|
||||
* @return array|null Parsed JSON attributes, if present and valid, otherwise `null`.
|
||||
*/
|
||||
public function allocate_and_return_parsed_attributes(): ?array {
|
||||
$this->last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 0 === $this->json_length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json_span = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
$parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE );
|
||||
|
||||
$last_error = json_last_error();
|
||||
$this->last_json_error = $last_error;
|
||||
|
||||
return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) )
|
||||
? $parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
// Debugging methods not meant for production use.
|
||||
|
||||
/**
|
||||
* Prints a debugging message showing the structure of the parsed delimiter.
|
||||
*
|
||||
* This is not meant to be used in production!
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
public function debug_print_structure(): void {
|
||||
$c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) )
|
||||
? function ( $color = null ) { return $color; } // phpcs:ignore
|
||||
: function ( $color ) { return ''; }; // phpcs:ignore
|
||||
|
||||
if ( $this->is_block_type( 'core/freeform' ) ) {
|
||||
$closer = static::CLOSER === $this->get_delimiter_type() ? '/' : '';
|
||||
echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
return;
|
||||
}
|
||||
|
||||
$namespace = substr( $this->source_text, $this->namespace_at, $this->namespace_length );
|
||||
$slash = 0 === $this->namespace_length ? '' : '/';
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
$closer = static::CLOSER === $this->type ? '/' : '';
|
||||
$json = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
|
||||
$opener_whitespace_at = $this->delimiter_at + 4;
|
||||
$opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 );
|
||||
|
||||
$after_name_whitespace_at = $this->name_at + $this->name_length;
|
||||
$after_name_whitespace_length = $this->json_at - $after_name_whitespace_at;
|
||||
|
||||
$closing_whitespace_at = $this->json_at + $this->json_length;
|
||||
$closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at;
|
||||
|
||||
if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) {
|
||||
$void_flag = '/';
|
||||
--$closing_whitespace_length;
|
||||
} else {
|
||||
$void_flag = '';
|
||||
}
|
||||
|
||||
$w = function ( $whitespace ) use ( $c ) {
|
||||
return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '' ), $whitespace );
|
||||
};
|
||||
|
||||
echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore
|
||||
echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
}
|
||||
|
||||
// Constant declarations that would otherwise pollute the top of the class.
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter closes an open block.
|
||||
*/
|
||||
const CLOSER = 'closer';
|
||||
|
||||
/**
|
||||
* Indicates that the parser started parsing a block comment delimiter, but
|
||||
* the input document ended before it could finish. The document was likely truncated.
|
||||
*/
|
||||
const INCOMPLETE_INPUT = 'incomplete-input';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter opens a block.
|
||||
*/
|
||||
const OPENER = 'opener';
|
||||
|
||||
/**
|
||||
* Indicates that the parser has not yet attempted to parse a block comment delimiter.
|
||||
*/
|
||||
const UNINITIALIZED = 'uninitialized';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter represents a void block
|
||||
* with no inner content of any kind.
|
||||
*/
|
||||
const VOID = 'void';
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
<?php
|
||||
/**
|
||||
* Efficiently scan through block structure in document without parsing
|
||||
* the entire block tree and all of its JSON attributes into memory.
|
||||
*
|
||||
* @package automattic/block-delimiter
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic;
|
||||
|
||||
use Exception;
|
||||
use WP_HTML_Span;
|
||||
|
||||
/**
|
||||
* Class for efficiently scanning through block structure in a document
|
||||
* without parsing the entire block tree and JSON attributes into memory.
|
||||
*
|
||||
* This class follows design values of the HTML API:
|
||||
* - minimize allocations and strive for zero memory overhead
|
||||
* - make costs explicit; pay only for what you need
|
||||
* - follow a streaming, re-entrant design for pausing and aborting
|
||||
*
|
||||
* For usage, jump straight to {@see self::next_delimiter}.
|
||||
*/
|
||||
class Block_Scanner {
|
||||
/**
|
||||
* Indicates if the last operation failed, otherwise
|
||||
* will be `null` for success.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private $last_error = null;
|
||||
|
||||
/**
|
||||
* Indicates failures from decoding JSON attributes.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
/**
|
||||
* Holds a reference to the original source text from which to
|
||||
* extract the parsed spans of the delimiter.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $source_text;
|
||||
|
||||
/**
|
||||
* Byte offset into source text where entire delimiter begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of full span of delimiter.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $delimiter_length = 0;
|
||||
|
||||
/**
|
||||
* Byte offset where namespace span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of namespace span, or `0` if implicitly in the "core" namespace.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $namespace_length = 0;
|
||||
|
||||
/**
|
||||
* Byte offset where block name span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_at = 0;
|
||||
|
||||
/**
|
||||
* Byte length of block name span.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $name_length = 0;
|
||||
|
||||
/**
|
||||
* Whether the delimiter contains the block self-closing flag.
|
||||
*
|
||||
* This may be erroneous if present within a block closer,
|
||||
* therefore the {@see self::has_void_flag} can be used by
|
||||
* calling code to perform appropriate error-handling.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $has_void_flag = false;
|
||||
|
||||
/**
|
||||
* Byte offset where JSON attributes span begins.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_at;
|
||||
|
||||
/**
|
||||
* Byte length of JSON attributes span, or `0` if none are present.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private $json_length;
|
||||
|
||||
/**
|
||||
* Indicates what kind of block comment delimiter this represents.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER` If the delimiter is opening a block.
|
||||
* - `static::CLOSER` If the delimiter is closing an open block.
|
||||
* - `static::VOID` If the delimiter represents a void block with no inner content.
|
||||
*
|
||||
* If a parsed comment delimiter contains both the closing and the void
|
||||
* flags then it will be interpreted as a void block to match the behavior
|
||||
* of the official block parser, however, this is a mistake and probably
|
||||
* the block ought to close an open block of the same name, if one is open.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $type;
|
||||
|
||||
/**
|
||||
* Creates a new block scanner.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* echo "Found an image!\n";
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* This function is currently a stub so that future improvements can add configuration
|
||||
* options and reject creation, which cannot occur directly inside class constructors.
|
||||
*
|
||||
* @see self::next_delimiter
|
||||
*
|
||||
* @param string $source_text Input document potentially containing block content.
|
||||
* @return ?self Created block scanner, if successfully created.
|
||||
*/
|
||||
public static function create( string $source_text ): ?self {
|
||||
return new self( $source_text );
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan to the next block delimiter in a document, indicating if one was found.
|
||||
*
|
||||
* Block comment delimiters must be valid HTML comments and may contain JSON.
|
||||
* This search does not determine, however, if the JSON is valid.
|
||||
*
|
||||
* Example delimiters:
|
||||
*
|
||||
* `<!-- wp:paragraph {"dropCap": true} -->`
|
||||
* `<!-- wp:separator /-->`
|
||||
* `<!-- /wp:paragraph -->`
|
||||
*
|
||||
* In the case that a block comment delimiter contains both the void indicator and
|
||||
* also the closing indicator, it will be treated as a void block.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Find all image block opening delimiters.
|
||||
* $images = array();
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* $images[] = $scanner->get_span();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Not all blocks have explicit delimiters. Non-block content at the top-level of
|
||||
* a document (so-called “HTML soup”) forms implicit blocks containing neither a
|
||||
* block name nor block attributes. Because this content often comprises only
|
||||
* HTML whitespace and adds undo performance burden, it is skipped by default.
|
||||
* To scan the implicit freeform blocks, pass the `$freeform_blocks` argument.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $html = '<!-- wp:void /-->\n<!-- wp:void /-->';
|
||||
* $blocks = [
|
||||
* [ 'blockName' => 'core/void' ],
|
||||
* [ 'blockName' => null ],
|
||||
* [ 'blockName' => 'core/void' ],
|
||||
* ];
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter( freeform_blocks: 'visit' ) {
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* In some cases it may be useful to conditionally visit the implicit freeform
|
||||
* blocks, such as when determining if a post contains freeform content that
|
||||
* isn’t purely whitespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $seen_block_types = [];
|
||||
* $freeform_blocks = 'visit';
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter( freeform_blocks: $freeform_blocks ) {
|
||||
* if ( ! $scanner->opens_block() ) {
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* // Stop wasting time visiting freeform blocks after one has been found.
|
||||
* if ('visit' === $freeform_blocks ) {
|
||||
* if ( $scanner->is_non_whitespace_freeform() ) {
|
||||
* $freeform_blocks = 'skip';
|
||||
* $seen_block_types['core/freeform'] = true;
|
||||
* }
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* $seen_block_types[ $scanner->get_block_type() ] = true;
|
||||
* }
|
||||
*
|
||||
* @param string $freeform_blocks Optional. Pass `visit` to match freeform HTML content
|
||||
* not surrounded by block delimiters. Defaults to `skip`.
|
||||
* @return bool Whether a block delimiter was matched.
|
||||
*/
|
||||
public function next_delimiter( string $freeform_blocks = 'skip' ): bool { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
if ( $this->last_error ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$text = $this->source_text;
|
||||
$end = strlen( $text );
|
||||
$at = $this->delimiter_at + $this->delimiter_length;
|
||||
$found_one = false;
|
||||
|
||||
while ( $at < $end ) {
|
||||
/*
|
||||
* Find the next possible opening.
|
||||
*
|
||||
* This follows the behavior in the official block parser, which treats a post
|
||||
* as a list of blocks with nested HTML. If HTML comment syntax appears within
|
||||
* an HTML attribute value, SCRIPT or STYLE element, or in other select places,
|
||||
* which it can do inside of HTML, then the block parsing may break.
|
||||
*
|
||||
* For a more robust parse scan through the document with the HTML API. In
|
||||
* practice, this has not been a problem in the entire history of blocks.
|
||||
*/
|
||||
$comment_opening_at = strpos( $text, '<!--', $at );
|
||||
if ( false === $comment_opening_at ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$opening_whitespace_at = $comment_opening_at + 4;
|
||||
$opening_whitespace_length = strspn( $text, " \t\f\r\n", $opening_whitespace_at );
|
||||
if ( 0 === $opening_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$wp_prefix_at = $opening_whitespace_at + $opening_whitespace_length;
|
||||
if ( $wp_prefix_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$has_closer = false;
|
||||
if ( '/' === $text[ $wp_prefix_at ] ) {
|
||||
$has_closer = true;
|
||||
++$wp_prefix_at;
|
||||
}
|
||||
|
||||
if ( 0 !== substr_compare( $text, 'wp:', $wp_prefix_at, 3 ) ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_at = $wp_prefix_at + 3;
|
||||
if ( $namespace_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$start_of_namespace = $text[ $namespace_at ];
|
||||
|
||||
// The namespace must start with a-z.
|
||||
if ( 'a' > $start_of_namespace || 'z' < $start_of_namespace ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $namespace_at + 1 );
|
||||
$separator_at = $namespace_at + $namespace_length;
|
||||
if ( $separator_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
$has_separator = '/' === $text[ $separator_at ];
|
||||
if ( $has_separator ) {
|
||||
$name_at = $separator_at + 1;
|
||||
$start_of_name = $text[ $name_at ];
|
||||
if ( 'a' > $start_of_name || 'z' < $start_of_name ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$name_length = 1 + strspn( $text, 'abcdefghijklmnopqrstuvwxyz0123456789-_', $name_at + 1 );
|
||||
} else {
|
||||
$name_at = $namespace_at;
|
||||
$name_length = $namespace_length;
|
||||
$namespace_length = 0;
|
||||
}
|
||||
|
||||
$after_name_whitespace_at = $name_at + $name_length;
|
||||
$after_name_whitespace_length = strspn( $text, " \t\f\r\n", $after_name_whitespace_at );
|
||||
if ( 0 === $after_name_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_at = $after_name_whitespace_at + $after_name_whitespace_length;
|
||||
if ( $json_at >= $end ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
$has_json = '{' === $text[ $json_at ];
|
||||
$json_length = 0;
|
||||
|
||||
/*
|
||||
* For the final span of the delimiter it's most efficient to find the end
|
||||
* of the HTML comment and work backwards. This prevents complicated parsing
|
||||
* inside the JSON span, which cannot contain the HTML comment terminator.
|
||||
*
|
||||
* This also matches the behavior in the official block parser, though it
|
||||
* allows for matching invalid JSON content.
|
||||
*/
|
||||
$comment_closing_at = strpos( $text, '-->', $json_at );
|
||||
if ( false === $comment_closing_at ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
* It looks like this logic leaves an error in here, when the position
|
||||
* overlaps the JSON or block name. However, for neither of those is it
|
||||
* possible to parse a valid block if that last overlapping character
|
||||
* is the void flag. This, therefore, will be valid regardless of how
|
||||
* the rest of the comment delimiter is written.
|
||||
*/
|
||||
if ( '/' === $text[ $comment_closing_at - 1 ] ) {
|
||||
$has_void_flag = true;
|
||||
$void_flag_length = 1;
|
||||
} else {
|
||||
$has_void_flag = false;
|
||||
$void_flag_length = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* If there's no JSON, then the span of text after the name
|
||||
* until the comment closing must be completely whitespace.
|
||||
*/
|
||||
if ( ! $has_json ) {
|
||||
$max_whitespace_length = $comment_closing_at - $json_at - $void_flag_length;
|
||||
|
||||
// This shouldn't be possible, but it can't be allowed regardless.
|
||||
if ( $max_whitespace_length < 0 ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
$closing_whitespace_length = strspn( $text, " \t\f\r\n", $json_at, $comment_closing_at - $json_at - $void_flag_length );
|
||||
if ( 0 === $after_name_whitespace_length + $closing_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$found_one = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// There's no JSON, so attempt to find its boundary.
|
||||
$after_json_whitespace_length = 0;
|
||||
for ( $char_at = $comment_closing_at - $void_flag_length - 1; $char_at > $json_at; $char_at-- ) {
|
||||
$char = $text[ $char_at ];
|
||||
|
||||
switch ( $char ) {
|
||||
case ' ':
|
||||
case "\t":
|
||||
case "\f":
|
||||
case "\r":
|
||||
case "\n":
|
||||
++$after_json_whitespace_length;
|
||||
continue 2;
|
||||
|
||||
case '}':
|
||||
$json_length = $char_at - $json_at + 1;
|
||||
break 2;
|
||||
|
||||
default:
|
||||
++$at;
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
|
||||
if ( 0 === $json_length || 0 === $after_json_whitespace_length ) {
|
||||
$at = $this->find_html_comment_end( $comment_opening_at, $end );
|
||||
continue;
|
||||
}
|
||||
|
||||
// This must be a block delimiter!
|
||||
$found_one = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if ( ! $found_one ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->delimiter_at = $comment_opening_at;
|
||||
$this->delimiter_length = $comment_closing_at + 3 - $comment_opening_at;
|
||||
|
||||
$this->namespace_at = $namespace_at;
|
||||
$this->namespace_length = $namespace_length;
|
||||
|
||||
$this->name_at = $name_at;
|
||||
$this->name_length = $name_length;
|
||||
|
||||
$this->json_at = $json_at;
|
||||
$this->json_length = $json_length;
|
||||
|
||||
$this->type = $has_closer
|
||||
? static::CLOSER
|
||||
: ( $has_void_flag ? static::VOID : static::OPENER );
|
||||
|
||||
$this->has_void_flag = $has_void_flag;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor function.
|
||||
*
|
||||
* @param string $source_text Input document potentially containing block content.
|
||||
*/
|
||||
private function __construct( string $source_text ) {
|
||||
$this->source_text = $source_text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the byte-offset after the ending character of an HTML comment,
|
||||
* assuming the proper starting byte offset.
|
||||
*
|
||||
* @param int $comment_starting_at Where the HTML comment started, the leading `<`.
|
||||
* @param int $search_end Last offset in which to search, for limiting search span.
|
||||
* @return int Offset after the current HTML comment ends, or `$end` if no end was found.
|
||||
*/
|
||||
private function find_html_comment_end( int $comment_starting_at, int $search_end ): int {
|
||||
$text = $this->source_text;
|
||||
|
||||
// Find span-of-dashes comments which look like `<!----->`.
|
||||
$span_of_dashes = strspn( $text, '-', $comment_starting_at + 2 );
|
||||
if (
|
||||
$comment_starting_at + 2 + $span_of_dashes < $search_end &&
|
||||
'>' === $text[ $comment_starting_at + 2 + $span_of_dashes ]
|
||||
) {
|
||||
return $comment_starting_at + $span_of_dashes + 1;
|
||||
}
|
||||
|
||||
// Otherwise, there are other characters inside the comment, find the first `-->` or `--!>`.
|
||||
$now_at = $comment_starting_at + 4;
|
||||
while ( $now_at < $search_end ) {
|
||||
$dashes_at = strpos( $text, '--', $now_at );
|
||||
if ( false === $dashes_at ) {
|
||||
$this->last_error = self::INCOMPLETE_INPUT;
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
$closer_must_be_at = $dashes_at + 2 + strspn( $text, '-', $dashes_at + 2 );
|
||||
if ( $closer_must_be_at < $search_end && '!' === $text[ $closer_must_be_at ] ) {
|
||||
$closer_must_be_at++;
|
||||
}
|
||||
|
||||
if ( $closer_must_be_at < $search_end && '>' === $text[ $closer_must_be_at ] ) {
|
||||
return $closer_must_be_at + 1;
|
||||
}
|
||||
|
||||
$now_at++;
|
||||
}
|
||||
|
||||
return $search_end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block comment delimiter
|
||||
* failed, if set, otherwise `null` if the last attempt succeeded.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_last_error() {
|
||||
return $this->last_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the last attempt to parse a block’s JSON attributes failed.
|
||||
*
|
||||
* @see JSON_ERROR_NONE, JSON_ERROR_DEPTH, etc…
|
||||
*
|
||||
* @return int JSON_ERROR_ code from last attempt to parse block JSON attributes.
|
||||
*/
|
||||
public function get_last_json_error(): int {
|
||||
return $this->last_json_error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the block comment delimiter.
|
||||
*
|
||||
* One of:
|
||||
*
|
||||
* - `static::OPENER`
|
||||
* - `static::CLOSER`
|
||||
* - `static::VOID`
|
||||
*
|
||||
* @return string type of the block comment delimiter.
|
||||
*/
|
||||
public function get_delimiter_type(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the delimiter contains the void flag.
|
||||
*
|
||||
* This should be avoided except in cases of handling errors with
|
||||
* block closers containing the void flag. For normative use,
|
||||
* {@see self::get_delimiter_type}.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_void_flag(): bool {
|
||||
return $this->has_void_flag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the block delimiter represents a block of the given type.
|
||||
*
|
||||
* Since the "core" namespace may be implicit, it's allowable to pass
|
||||
* either the fully-qualified block type with namespace and block name
|
||||
* as well as the shorthand version only containing the block name, if
|
||||
* the desired block is in the "core" namespace.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $is_core_paragraph = $scanner->is_block_type( 'paragraph' );
|
||||
* $is_core_paragraph = $scanner->is_block_type( 'core/paragraph' );
|
||||
* $is_formula = $scanner->is_block_type( 'math-block/formula' );
|
||||
*
|
||||
* @param string $block_type Block type name for the desired block.
|
||||
* E.g. "paragraph", "core/paragraph", "math-blocks/formula".
|
||||
* @return bool Whether this delimiter represents a block of the given type.
|
||||
*/
|
||||
public function is_block_type( string $block_type ): bool {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform' === $block_type || 'freeform' === $block_type;
|
||||
}
|
||||
|
||||
$slash_at = strpos( $block_type, '/' );
|
||||
if ( false === $slash_at ) {
|
||||
$namespace = 'core';
|
||||
$block_name = $block_type;
|
||||
} else {
|
||||
// @todo Get lengths but avoid the allocation, use substr_compare below.
|
||||
$namespace = substr( $block_type, 0, $slash_at );
|
||||
$block_name = substr( $block_type, $slash_at + 1 );
|
||||
}
|
||||
|
||||
// Only the 'core' namespace is allowed to be omitted.
|
||||
if ( 0 === $this->namespace_length && 'core' !== $namespace ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If given an explicit namespace, they must match.
|
||||
if (
|
||||
0 !== $this->namespace_length && (
|
||||
strlen( $namespace ) !== $this->namespace_length ||
|
||||
0 !== substr_compare( $this->source_text, $namespace, $this->namespace_at, $this->namespace_length )
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The block name must match.
|
||||
return (
|
||||
strlen( $block_name ) === $this->name_length &&
|
||||
0 === substr_compare( $this->source_text, $block_name, $this->name_at, $this->name_length )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is an opening or void delimiter
|
||||
* (i.e. it opens the block) of the given type, if a type is provided.
|
||||
*
|
||||
* This is a helper method to ease handling of code inspecting where
|
||||
* blocks start, and of checking if the blocks are of a given type.
|
||||
* The function is variadic to allow for checking if the delimiter
|
||||
* opens one of many possible block types.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( $html );
|
||||
* while ( $scanner->next_delimiter() ) {
|
||||
* if ( $scanner->opens_block( 'core/code', 'syntaxhighlighter/code' ) ) {
|
||||
* echo "Found code!";
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* if ( $scanner->opens_block( 'core/image' ) ) {
|
||||
* echo "Found an image!";
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* if ( $scanner->opens_block() ) {
|
||||
* echo "Found a new block!";
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @see self::is_block_type
|
||||
*
|
||||
* @param string|null ...$block_type Optional. Is the matched block type one of these?
|
||||
* If none are provided, will not test block type.
|
||||
* @return bool Whether the matched block delimiter opens a block, and whether it
|
||||
* opens a block of one of the given block types, if provided.
|
||||
*/
|
||||
public function opens_block( ...$block_type ): bool {
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( count( $block_type ) === 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ( $block_type as $block ) {
|
||||
if ( $this->is_block_type( $block ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is implied due to top-level
|
||||
* non-block content in the post.
|
||||
*
|
||||
* @see self::is_non_whitespace_freeform
|
||||
*
|
||||
* @return bool Whether or not the matched delimiter is implied as `core/freeform`.
|
||||
*/
|
||||
public function is_freeform(): bool {
|
||||
return 0 === $this->name_length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the matched delimiter is implicit and surrounding
|
||||
* top-level non-block content that contains non-whitespace text.
|
||||
*
|
||||
* Many block serializers introduce newlines between block delimiters,
|
||||
* so the presence of top-level non-block content does not imply that
|
||||
* there are “real” freeform HTML blocks. Checking if there is content
|
||||
* beyond whitespace is a more certain check, such as for determining
|
||||
* whether to load CSS for the freeform or fallback block type.
|
||||
*
|
||||
* @see self::is_freeform
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_non_whitespace_freeform(): bool {
|
||||
if ( 0 !== $this->name_length ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, return false as this method is not yet fully implemented.
|
||||
// @todo Implement logic to check if freeform content contains non-whitespace text.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates a substring for the block type and returns the
|
||||
* fully-qualified name, including the namespace.
|
||||
*
|
||||
* This function allocates a substring for the given block type. This
|
||||
* allocation will be small and likely fine in most cases, but it's
|
||||
* preferable to call {@link self::is_block_type} if only needing
|
||||
* to know whether the delimiter is for a given block type, as that
|
||||
* function is more efficient for this purpose and avoids the allocation.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* // Avoid.
|
||||
* 'core/paragraph' = $scanner->get_block_type();
|
||||
*
|
||||
* // Prefer.
|
||||
* $scanner->is_block_type( 'core/paragraph' );
|
||||
* $scanner->is_block_type( 'paragraph' );
|
||||
*
|
||||
* @return string Fully-qualified block namespace and type, e.g. "core/paragraph".
|
||||
*/
|
||||
public function get_block_type(): string {
|
||||
// This is a core/freeform text block, it’s special.
|
||||
if ( 0 === $this->name_length ) {
|
||||
return 'core/freeform';
|
||||
}
|
||||
|
||||
// This is implicitly in the "core" namespace.
|
||||
if ( 0 === $this->namespace_length ) {
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
return "core/{$block_name}";
|
||||
}
|
||||
|
||||
return substr( $this->source_text, $this->namespace_at, $this->namespace_length + $this->name_length + 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a lazy wrapper around the block attributes, which can be used
|
||||
* for efficiently interacting with the JSON attributes.
|
||||
*
|
||||
* @throws Exception This function is not yet implemented.
|
||||
*
|
||||
* @todo Create a lazy JSON wrapper so specific attributes can be
|
||||
* efficiently queried without parsing everything and loading
|
||||
* the entire object into memory.
|
||||
* @todo After realistic benchmarking, see if JsonStreamingParser\Parser
|
||||
* could be used — it would need to be fast enough for the reduction
|
||||
* in memory use to be worth it, compared to {@see \json_decode}.
|
||||
*
|
||||
* @see \JsonStreamingParser\Parser
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
public function get_attributes(): void {
|
||||
throw new Exception( 'Lazy attribute parsing not yet supported' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse and return the entire JSON attributes from the delimiter,
|
||||
* allocating memory and processing the JSON span in the process.
|
||||
*
|
||||
* This does not return any parsed attributes for a closing block delimiter
|
||||
* even if there is a span of JSON content; this JSON is a parsing error.
|
||||
*
|
||||
* Consider calling {@link self::get_attributes} instead if it's not
|
||||
* necessary to read all the attributes at the same time, as that provides
|
||||
* a more efficient mechanism for typical use cases.
|
||||
*
|
||||
* Since the JSON span inside the comment delimiter may not be valid JSON,
|
||||
* this function will return `null` if it cannot parse the span and set the
|
||||
* {@see self::get_last_json_error} to the appropriate JSON_ERROR_ constant.
|
||||
*
|
||||
* If the delimiter contains no JSON span, it will also return `null`,
|
||||
* but the last error will be set to {@see JSON_ERROR_NONE}.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:image {"url": "https://wordpress.org/favicon.ico"} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* $memory_hungry_and_slow_attributes = $scanner->allocate_and_return_parsed_attributes();
|
||||
* $memory_hungry_and_slow_attributes === array( 'url' => 'https://wordpress.org/favicon.ico' );
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- /wp:image {"url": "https://wordpress.org/favicon.ico"} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_NONE = $scanner->get_last_json_error();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:separator {} /-->' );
|
||||
* $scanner->next_delimiter();
|
||||
* array() === $scanner->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:separator /-->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:image {"url} -->' );
|
||||
* $scanner->next_delimiter();
|
||||
* null = $scanner->allocate_and_return_parsed_attributes();
|
||||
* JSON_ERROR_CTRL_CHAR = $scanner->get_last_json_error();
|
||||
*
|
||||
* @return array|null Parsed JSON attributes, if present and valid, otherwise `null`.
|
||||
*/
|
||||
public function allocate_and_return_parsed_attributes(): ?array {
|
||||
$this->last_json_error = JSON_ERROR_NONE;
|
||||
|
||||
if ( static::CLOSER === $this->type ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( 0 === $this->json_length ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json_span = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
$parsed = json_decode( $json_span, null, 512, JSON_OBJECT_AS_ARRAY | JSON_INVALID_UTF8_SUBSTITUTE );
|
||||
|
||||
$last_error = json_last_error();
|
||||
$this->last_json_error = $last_error;
|
||||
|
||||
return ( JSON_ERROR_NONE === $last_error && is_array( $parsed ) )
|
||||
? $parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the span representing the currently-matched delimiter,
|
||||
* if matched, otherwise `null`.
|
||||
*
|
||||
* Note that for freeform blocks this will return a span of length
|
||||
* zero, since there is no explicit block delimiter.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* $scanner = Block_Scanner::create( '<!-- wp:void /-->' );
|
||||
* null === $scanner->get_span();
|
||||
*
|
||||
* $scanner->next_delimiter();
|
||||
* WP_HTML_Span( 0, 17 ) === $scanner->get_span();
|
||||
*
|
||||
* @return WP_HTML_Span|null Span of text in source text spanning matched delimiter.
|
||||
*/
|
||||
public function get_span(): ?WP_HTML_Span {
|
||||
return new WP_HTML_Span( $this->delimiter_at, $this->delimiter_length );
|
||||
}
|
||||
|
||||
// Debugging methods not meant for production use.
|
||||
|
||||
/**
|
||||
* Prints a debugging message showing the structure of the parsed delimiter.
|
||||
*
|
||||
* This is not meant to be used in production!
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
public function debug_print_structure(): void {
|
||||
$c = ( ! defined( 'STDOUT' ) || posix_isatty( STDOUT ) )
|
||||
? function ( $color = null ) { return $color; } // phpcs:ignore
|
||||
: function ( $color ) { return ''; }; // phpcs:ignore
|
||||
|
||||
if ( $this->is_block_type( 'core/freeform' ) ) {
|
||||
$closer = static::CLOSER === $this->get_delimiter_type() ? '/' : '';
|
||||
echo "{$c( "\e[90m" )}<!-- "; // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}freeform"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )} {$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
return;
|
||||
}
|
||||
|
||||
$namespace = substr( $this->source_text, $this->namespace_at, $this->namespace_length );
|
||||
$slash = 0 === $this->namespace_length ? '' : '/';
|
||||
$block_name = substr( $this->source_text, $this->name_at, $this->name_length );
|
||||
$closer = static::CLOSER === $this->type ? '/' : '';
|
||||
$json = substr( $this->source_text, $this->json_at, $this->json_length );
|
||||
|
||||
$opener_whitespace_at = $this->delimiter_at + 4;
|
||||
$opener_whitespace_length = $this->namespace_at - 3 - $opener_whitespace_at - ( static::CLOSER === $this->type ? 1 : 0 );
|
||||
|
||||
$after_name_whitespace_at = $this->name_at + $this->name_length;
|
||||
$after_name_whitespace_length = $this->json_at - $after_name_whitespace_at;
|
||||
|
||||
$closing_whitespace_at = $this->json_at + $this->json_length;
|
||||
$closing_whitespace_length = $this->delimiter_at + $this->delimiter_length - 3 - $closing_whitespace_at;
|
||||
|
||||
if ( '/' === $this->source_text[ $this->delimiter_at + $this->delimiter_length - 4 ] ) {
|
||||
$void_flag = '/';
|
||||
--$closing_whitespace_length;
|
||||
} else {
|
||||
$void_flag = '';
|
||||
}
|
||||
|
||||
$w = function ( $whitespace ) use ( $c ) {
|
||||
return $c( "\e[2;90m" ) . str_replace( array( ' ', "\t", "\f", "\r", "\n" ), array( '␣', '␉', '␌', '␍', '' ), $whitespace );
|
||||
};
|
||||
|
||||
echo "{$c( "\e[90m" )}<!--"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $opener_whitespace_at, $opener_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;31m" )}{$closer}"; // phpcs:ignore
|
||||
echo "{$c("\e[90m" )}wp:{$c( "\e[2;34m" )}{$namespace}"; // phpcs:ignore
|
||||
echo "{$c( "\e[2;90m" )}{$slash}"; // phpcs:ignore
|
||||
echo "{$c( "\e[0;34m" )}{$block_name}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $after_name_whitespace_at, $after_name_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c("\e[0;2;32m" )}{$json}"; // phpcs:ignore
|
||||
echo $w( substr( $this->source_text, $closing_whitespace_at, $closing_whitespace_length ) ); // phpcs:ignore
|
||||
echo "{$c( "\e[0;36m" )}{$void_flag}{$c("\e[90m")}-->\n"; // phpcs:ignore
|
||||
}
|
||||
|
||||
// Constant declarations that would otherwise pollute the top of the class.
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter closes an open block.
|
||||
*/
|
||||
const CLOSER = 'closer';
|
||||
|
||||
/**
|
||||
* Indicates that the parser started parsing a block comment delimiter, but
|
||||
* the input document ended before it could finish. The document was likely truncated.
|
||||
*/
|
||||
const INCOMPLETE_INPUT = 'incomplete-input';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter opens a block.
|
||||
*/
|
||||
const OPENER = 'opener';
|
||||
|
||||
/**
|
||||
* Indicates that the parser has not yet attempted to parse a block comment delimiter.
|
||||
*/
|
||||
const UNINITIALIZED = 'uninitialized';
|
||||
|
||||
/**
|
||||
* Indicates that the block comment delimiter represents a void block
|
||||
* with no inner content of any kind.
|
||||
*/
|
||||
const VOID = 'void';
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.0.10] - 2026-06-15
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.9] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.8] - 2026-06-03
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.7] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.6] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [3.0.5] - 2025-04-28
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.4] - 2025-03-21
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.3] - 2025-03-12
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.2] - 2025-03-05
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [3.0.1] - 2025-02-24
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [3.0.0] - 2024-11-14
|
||||
### Removed
|
||||
- General: Update minimum PHP version to 7.2. [#40147]
|
||||
|
||||
## [2.0.4] - 2024-11-04
|
||||
### Added
|
||||
- Enable test coverage. [#39961]
|
||||
|
||||
## [2.0.3] - 2024-09-30
|
||||
### Fixed
|
||||
- Added a check for function presence to avoid fatal errors. [#39581]
|
||||
|
||||
## [2.0.2] - 2024-08-23
|
||||
### Changed
|
||||
- Updated package dependencies. [#39004]
|
||||
|
||||
## [2.0.1] - 2024-03-12
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [2.0.0] - 2023-11-20
|
||||
### Changed
|
||||
- Updated required PHP version to >= 7.0. [#34192]
|
||||
|
||||
## [1.4.22] - 2023-09-19
|
||||
|
||||
- Minor internal updates.
|
||||
|
||||
## [1.4.21] - 2023-08-23
|
||||
### Changed
|
||||
- Updated package dependencies. [#32605]
|
||||
|
||||
## [1.4.20] - 2023-04-10
|
||||
### Added
|
||||
- Add Jetpack Autoloader package suggestion. [#29988]
|
||||
|
||||
## [1.4.19] - 2023-02-20
|
||||
### Changed
|
||||
- Minor internal updates.
|
||||
|
||||
## [1.4.18] - 2023-01-11
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.17] - 2022-12-02
|
||||
### Changed
|
||||
- Updated package dependencies. [#27688]
|
||||
|
||||
## [1.4.16] - 2022-11-22
|
||||
### Changed
|
||||
- Updated package dependencies. [#27043]
|
||||
|
||||
## [1.4.15] - 2022-07-26
|
||||
### Changed
|
||||
- Updated package dependencies. [#25158]
|
||||
|
||||
## [1.4.14] - 2022-06-21
|
||||
### Changed
|
||||
- Renaming master to trunk.
|
||||
|
||||
## [1.4.13] - 2022-04-26
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.12] - 2022-01-25
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.11] - 2022-01-04
|
||||
### Changed
|
||||
- Switch to pcov for code coverage.
|
||||
- Updated package dependencies
|
||||
|
||||
## [1.4.10] - 2021-12-14
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.9] - 2021-11-02
|
||||
### Changed
|
||||
- Set `convertDeprecationsToExceptions` true in PHPUnit config.
|
||||
- Update PHPUnit configs to include just what needs coverage rather than include everything then try to exclude stuff that doesn't.
|
||||
|
||||
## [1.4.8] - 2021-10-13
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.7] - 2021-10-12
|
||||
### Changed
|
||||
- Updated package dependencies
|
||||
|
||||
## [1.4.6] - 2021-09-28
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.5] - 2021-08-30
|
||||
### Changed
|
||||
- Run composer update on test-php command instead of phpunit
|
||||
- Tests: update PHPUnit polyfills dependency (yoast/phpunit-polyfills).
|
||||
- updated annotations versions
|
||||
|
||||
## [1.4.4] - 2021-05-25
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [1.4.3] - 2021-04-08
|
||||
### Changed
|
||||
- Packaging and build changes, no change to the package itself.
|
||||
|
||||
## [1.4.2] - 2021-03-30
|
||||
### Added
|
||||
- Composer alias for dev-master, to improve dependencies
|
||||
|
||||
### Changed
|
||||
- Update package dependencies.
|
||||
|
||||
### Fixed
|
||||
- Use `composer update` rather than `install` in scripts, as composer.lock isn't checked in.
|
||||
|
||||
## [1.4.1] - 2021-02-05
|
||||
|
||||
- CI: Make tests more generic
|
||||
|
||||
## [1.4.0] - 2021-01-20
|
||||
|
||||
- Add mirror-repo information to all current composer packages
|
||||
|
||||
## [1.3.0] - 2020-12-17
|
||||
|
||||
- Coverage Update whitelist for backend tests
|
||||
- Pin dependencies
|
||||
- Packages: Update for PHP 8 testing
|
||||
|
||||
## [1.2.0] - 2020-09-17
|
||||
|
||||
## [1.1.1] - 2020-09-17
|
||||
|
||||
- a8c-mc-stats: Do not distribute test files
|
||||
|
||||
## [1.1.0] - 2020-08-13
|
||||
|
||||
- CI: Try collect js coverage
|
||||
|
||||
## 1.0.0 - 2020-07-27
|
||||
|
||||
- Creates the MC Stats package
|
||||
|
||||
[3.0.10]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.9...v3.0.10
|
||||
[3.0.9]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.8...v3.0.9
|
||||
[3.0.8]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.7...v3.0.8
|
||||
[3.0.7]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.6...v3.0.7
|
||||
[3.0.6]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.5...v3.0.6
|
||||
[3.0.5]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.4...v3.0.5
|
||||
[3.0.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.3...v3.0.4
|
||||
[3.0.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.2...v3.0.3
|
||||
[3.0.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.1...v3.0.2
|
||||
[3.0.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v3.0.0...v3.0.1
|
||||
[3.0.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.4...v3.0.0
|
||||
[2.0.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.3...v2.0.4
|
||||
[2.0.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.2...v2.0.3
|
||||
[2.0.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.1...v2.0.2
|
||||
[2.0.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v2.0.0...v2.0.1
|
||||
[2.0.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.22...v2.0.0
|
||||
[1.4.22]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.21...v1.4.22
|
||||
[1.4.21]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.20...v1.4.21
|
||||
[1.4.20]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.19...v1.4.20
|
||||
[1.4.19]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.18...v1.4.19
|
||||
[1.4.18]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.17...v1.4.18
|
||||
[1.4.17]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.16...v1.4.17
|
||||
[1.4.16]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.15...v1.4.16
|
||||
[1.4.15]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.14...v1.4.15
|
||||
[1.4.14]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.13...v1.4.14
|
||||
[1.4.13]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.12...v1.4.13
|
||||
[1.4.12]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.11...v1.4.12
|
||||
[1.4.11]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.10...v1.4.11
|
||||
[1.4.10]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.9...v1.4.10
|
||||
[1.4.9]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.8...v1.4.9
|
||||
[1.4.8]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.7...v1.4.8
|
||||
[1.4.7]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.6...v1.4.7
|
||||
[1.4.6]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.5...v1.4.6
|
||||
[1.4.5]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.4...v1.4.5
|
||||
[1.4.4]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.3...v1.4.4
|
||||
[1.4.3]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.2...v1.4.3
|
||||
[1.4.2]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.1...v1.4.2
|
||||
[1.4.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.4.0...v1.4.1
|
||||
[1.4.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.3.0...v1.4.0
|
||||
[1.3.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.1.1...v1.2.0
|
||||
[1.1.1]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.1.0...v1.1.1
|
||||
[1.1.0]: https://github.com/Automattic/jetpack-a8c-mc-stats/compare/v1.0.0...v1.1.0
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Jetpack MC Stats package.
|
||||
*
|
||||
* @package automattic/jetpack-mc-stats
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack;
|
||||
|
||||
/**
|
||||
* Class MC Stats, used to record stats using https://pixel.wp.com/g.gif
|
||||
*/
|
||||
class A8c_Mc_Stats {
|
||||
|
||||
/**
|
||||
* Holds the stats to be processed
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $stats = array();
|
||||
|
||||
/**
|
||||
* Indicates whether to use the transparent pixel (b.gif) instead of the regular smiley (g.gif)
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
public $use_transparent_pixel = true;
|
||||
|
||||
/**
|
||||
* Class Constructor
|
||||
*
|
||||
* @param boolean $use_transparent_pixel Use the transparent pixel instead of the smiley.
|
||||
*/
|
||||
public function __construct( $use_transparent_pixel = true ) {
|
||||
$this->use_transparent_pixel = $use_transparent_pixel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a stat for later output.
|
||||
*
|
||||
* @param string $group The stat group.
|
||||
* @param string $name The stat name to bump.
|
||||
*
|
||||
* @return boolean true if stat successfully added
|
||||
*/
|
||||
public function add( $group, $name ) {
|
||||
|
||||
if ( ! \is_string( $group ) || ! \is_string( $name ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! isset( $this->stats[ $group ] ) ) {
|
||||
$this->stats[ $group ] = array();
|
||||
}
|
||||
|
||||
if ( \in_array( $name, $this->stats[ $group ], true ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->stats[ $group ][] = $name;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current stats stored to be processed
|
||||
*
|
||||
* @return array $stats
|
||||
*/
|
||||
public function get_current_stats() {
|
||||
return $this->stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stats from a group in an array ready to be added as parameters in a query string
|
||||
*
|
||||
* @param string $group_name The name of the group to retrieve.
|
||||
* @return array Array with one item, where the key is the prefixed group and the value are all stats concatenated with a comma. If group not found, an empty array will be returned
|
||||
*/
|
||||
public function get_group_query_args( $group_name ) {
|
||||
$stats = $this->get_current_stats();
|
||||
if ( isset( $stats[ $group_name ] ) && ! empty( $stats[ $group_name ] ) ) {
|
||||
return array( "x_jetpack-{$group_name}" => implode( ',', $stats[ $group_name ] ) );
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of trac URLs for every stored URL
|
||||
*
|
||||
* @return array An array of URLs
|
||||
*/
|
||||
public function get_stats_urls() {
|
||||
|
||||
$urls = array();
|
||||
|
||||
foreach ( $this->get_current_stats() as $group => $stat ) {
|
||||
$group_query_string = $this->get_group_query_args( $group );
|
||||
$urls[] = $this->build_stats_url( $group_query_string );
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs the tracking pixels for the current stats and empty the stored stats from the object
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function do_stats() {
|
||||
$urls = $this->get_stats_urls();
|
||||
foreach ( $urls as $url ) {
|
||||
echo '<img src="' . esc_url( $url ) . '" width="1" height="1" style="display:none;" />';
|
||||
}
|
||||
$this->stats = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pings the stats server for the current stats and empty the stored stats from the object
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function do_server_side_stats() {
|
||||
$urls = $this->get_stats_urls();
|
||||
foreach ( $urls as $url ) {
|
||||
$this->do_server_side_stat( $url );
|
||||
}
|
||||
$this->stats = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs stats code for a one-off, server-side.
|
||||
*
|
||||
* @param string $url string The URL to be pinged. Should include `x_jetpack-{$group}={$stats}` or whatever we want to store.
|
||||
*
|
||||
* @return bool If it worked.
|
||||
*/
|
||||
public function do_server_side_stat( $url ) {
|
||||
$response = wp_remote_get( esc_url_raw( $url ) );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the stats url.
|
||||
*
|
||||
* @param array $args array|string The arguments to append to the URL.
|
||||
*
|
||||
* @return string The URL to be pinged.
|
||||
*/
|
||||
public function build_stats_url( $args ) {
|
||||
$defaults = array(
|
||||
'v' => 'wpcom2',
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.rand_rand -- There can be a case where pluggables are not yet loaded.
|
||||
'rand' => md5( ( function_exists( 'wp_rand' ) ? wp_rand( 0, 999 ) : rand( 0, 999 ) ) . time() ),
|
||||
);
|
||||
$args = wp_parse_args( $args, $defaults );
|
||||
$gifname = true === $this->use_transparent_pixel ? 'b.gif' : 'g.gif';
|
||||
|
||||
/**
|
||||
* Filter the URL used as the Stats tracking pixel.
|
||||
*
|
||||
* @since-jetpack 2.3.2
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url Base URL used as the Stats tracking pixel.
|
||||
*/
|
||||
$base_url = apply_filters(
|
||||
'jetpack_stats_base_url',
|
||||
'https://pixel.wp.com/' . $gifname
|
||||
);
|
||||
$url = add_query_arg( $args, $base_url );
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.5] - 2026-06-25
|
||||
### Changed
|
||||
- Defer loading the password feature classes until the Account Protection module is active, reducing the per-request PHP/opcache footprint. [#49806]
|
||||
|
||||
## [0.3.4] - 2026-06-15
|
||||
### Added
|
||||
- Add `jetpack_account_protection_validation_min_length` and `jetpack_account_protection_validation_max_length` filters. [#49500]
|
||||
|
||||
## [0.3.3] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.2] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.1] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [0.3.0] - 2026-04-11
|
||||
### Added
|
||||
- Add `jetpack_account_protection_send_auth_email` filter to allow custom handling of the verification email. [#48003]
|
||||
|
||||
## [0.2.12] - 2026-02-23
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [0.2.11] - 2026-02-02
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [0.2.10] - 2025-12-15
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.9] - 2025-12-08
|
||||
### Fixed
|
||||
- Ensure proper flags are used with `json_encode()`. [#46092]
|
||||
|
||||
## [0.2.8] - 2025-11-10
|
||||
### Changed
|
||||
- Tests: Ensure PHP 8.5 compatibility. [#45769]
|
||||
|
||||
## [0.2.7] - 2025-08-04
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.6] - 2025-07-28
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.5] - 2025-07-21
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.4] - 2025-05-05
|
||||
### Fixed
|
||||
- Linting: Do additional stylesheet cleanup. [#43247]
|
||||
- Prevent PHP error from errant third-party hook params. [#43352]
|
||||
|
||||
## [0.2.3] - 2025-04-28
|
||||
### Fixed
|
||||
- Fix an issue where authentication could fail when other authentication plugins are active. [#43240]
|
||||
- Linting: Fix more Stylelint violations. [#43213]
|
||||
|
||||
## [0.2.2] - 2025-04-14
|
||||
### Fixed
|
||||
- Linting: Update stylesheets to use WordPress rules for fonts and colors. [#42920] [#42928]
|
||||
- Fix: Improve compatibility with code using stdClass objects in `profile_update` hook. [#43045]
|
||||
|
||||
## [0.2.1] - 2025-04-07
|
||||
### Changed
|
||||
- Linting: First pass of style coding standards. [#42734]
|
||||
|
||||
## [0.2.0] - 2025-04-01
|
||||
### Changed
|
||||
- Add a default value for the error param in the `wp_login_failed` action callback. [#42819]
|
||||
- Fix potential `wp_login_failed` action conflicts. [#42825]
|
||||
|
||||
## 0.1.0 - 2025-03-31
|
||||
### Added
|
||||
- Add initial account protection features. [#40925]
|
||||
- Add a user requires protection filter. [#40925]
|
||||
- Initial version. [#40923]
|
||||
- Use jetpack-config package for Account Protection initialization. [#40925]
|
||||
- Use jetpack-logo package for Account Protection logos. [#40925]
|
||||
|
||||
[0.3.5]: https://github.com/Automattic/jetpack-account-protection/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Automattic/jetpack-account-protection/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Automattic/jetpack-account-protection/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Automattic/jetpack-account-protection/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/jetpack-account-protection/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.12...v0.3.0
|
||||
[0.2.12]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.11...v0.2.12
|
||||
[0.2.11]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.10...v0.2.11
|
||||
[0.2.10]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.9...v0.2.10
|
||||
[0.2.9]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.8...v0.2.9
|
||||
[0.2.8]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.7...v0.2.8
|
||||
[0.2.7]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.4...v0.2.5
|
||||
[0.2.4]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/Automattic/jetpack-account-protection/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/jetpack-account-protection/compare/v0.1.0...v0.2.0
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Account Protection.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
use Automattic\Jetpack\Modules;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
|
||||
/**
|
||||
* Class Account_Protection
|
||||
*/
|
||||
class Account_Protection {
|
||||
const PACKAGE_VERSION = '0.3.5';
|
||||
const ACCOUNT_PROTECTION_MODULE_NAME = 'account-protection';
|
||||
|
||||
/**
|
||||
* Account_Protection instance
|
||||
*
|
||||
* @var Account_Protection
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Flag to track if hooks have been registered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $hooks_registered = false;
|
||||
|
||||
/**
|
||||
* Modules instance.
|
||||
*
|
||||
* @var Modules
|
||||
*/
|
||||
private $modules;
|
||||
|
||||
/**
|
||||
* Password detection instance.
|
||||
*
|
||||
* @var Password_Detection|null
|
||||
*/
|
||||
private $password_detection;
|
||||
|
||||
/**
|
||||
* Password manager instance
|
||||
*
|
||||
* @var Password_Manager|null
|
||||
*/
|
||||
private $password_manager;
|
||||
|
||||
/**
|
||||
* Password strength meter instance
|
||||
*
|
||||
* @var Password_Strength_Meter|null
|
||||
*/
|
||||
private $password_strength_meter;
|
||||
|
||||
/**
|
||||
* Initialize the Account_Protection instance
|
||||
*
|
||||
* @return Account_Protection
|
||||
*/
|
||||
public static function instance(): Account_Protection {
|
||||
if ( self::$instance === null ) {
|
||||
self::$instance = new Account_Protection();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the configurations needed for the account protection module.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function initialize(): void {
|
||||
if ( self::$hooks_registered ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->register_hooks();
|
||||
|
||||
if ( $this->is_enabled() ) {
|
||||
$this->register_runtime_hooks();
|
||||
}
|
||||
|
||||
self::$hooks_registered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account_Protection constructor.
|
||||
*
|
||||
* @param ?Modules $modules Modules instance.
|
||||
* @param ?Password_Detection $password_detection Password detection instance.
|
||||
* @param ?Password_Manager $password_manager Password manager instance.
|
||||
* @param ?Password_Strength_Meter $password_strength_meter Password strength meter instance.
|
||||
*/
|
||||
public function __construct( ?Modules $modules = null, ?Password_Detection $password_detection = null, ?Password_Manager $password_manager = null, ?Password_Strength_Meter $password_strength_meter = null ) {
|
||||
$this->modules = $modules ?? new Modules();
|
||||
|
||||
/*
|
||||
* The password collaborators are only used by the runtime hooks, which
|
||||
* only register when the module is active. Keep any injected instances
|
||||
* (used by tests) but otherwise leave them unset and create them lazily,
|
||||
* so their classes are not autoloaded on requests where the module is
|
||||
* disabled.
|
||||
*/
|
||||
$this->password_detection = $password_detection;
|
||||
$this->password_manager = $password_manager;
|
||||
$this->password_strength_meter = $password_strength_meter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily get the password detection instance.
|
||||
*
|
||||
* @return Password_Detection
|
||||
*/
|
||||
private function get_password_detection(): Password_Detection {
|
||||
if ( null === $this->password_detection ) {
|
||||
$this->password_detection = new Password_Detection();
|
||||
}
|
||||
return $this->password_detection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily get the password manager instance.
|
||||
*
|
||||
* @return Password_Manager
|
||||
*/
|
||||
private function get_password_manager(): Password_Manager {
|
||||
if ( null === $this->password_manager ) {
|
||||
$this->password_manager = new Password_Manager();
|
||||
}
|
||||
return $this->password_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily get the password strength meter instance.
|
||||
*
|
||||
* @return Password_Strength_Meter
|
||||
*/
|
||||
private function get_password_strength_meter(): Password_Strength_Meter {
|
||||
if ( null === $this->password_strength_meter ) {
|
||||
$this->password_strength_meter = new Password_Strength_Meter();
|
||||
}
|
||||
return $this->password_strength_meter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for module activation and environment validation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function register_hooks(): void {
|
||||
// Do not run in unsupported environments
|
||||
add_filter( 'jetpack_get_available_modules', array( $this, 'remove_module_on_unsupported_environments' ) );
|
||||
add_filter( 'jetpack_get_available_standalone_modules', array( $this, 'remove_standalone_module_on_unsupported_environments' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for runtime operations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function register_runtime_hooks(): void {
|
||||
$this->register_password_detection_hooks();
|
||||
$this->register_strong_passwords_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for password detection.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_password_detection_hooks(): void {
|
||||
$password_detection = $this->get_password_detection();
|
||||
add_action( 'wp_authenticate_user', array( $password_detection, 'login_form_password_detection' ), 10, 2 );
|
||||
add_action( 'login_form_password-detection', array( $password_detection, 'render_page' ), 10, 2 );
|
||||
add_action( 'wp_enqueue_scripts', array( $password_detection, 'enqueue_styles' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for password manager.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_password_manager_hooks(): void {
|
||||
$password_manager = $this->get_password_manager();
|
||||
add_action( 'user_profile_update_errors', array( $password_manager, 'validate_profile_update' ), 10, 3 );
|
||||
add_action( 'validate_password_reset', array( $password_manager, 'validate_password_reset' ), 10, 2 );
|
||||
add_action( 'profile_update', array( $password_manager, 'on_profile_update' ), 10, 2 );
|
||||
add_action( 'after_password_reset', array( $password_manager, 'on_password_reset' ), 10, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for password strength meter.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_password_strength_meter_hooks(): void {
|
||||
$password_strength_meter = $this->get_password_strength_meter();
|
||||
add_action( 'admin_enqueue_scripts', array( $password_strength_meter, 'enqueue_jetpack_password_strength_meter_profile_script' ) );
|
||||
add_action( 'login_enqueue_scripts', array( $password_strength_meter, 'enqueue_jetpack_password_strength_meter_reset_script' ) );
|
||||
add_action( 'wp_ajax_validate_password_ajax', array( $password_strength_meter, 'validate_password_ajax' ) );
|
||||
add_action( 'wp_ajax_nopriv_validate_password_ajax', array( $password_strength_meter, 'validate_password_ajax' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks for strong passwords.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_strong_passwords_hooks(): void {
|
||||
$this->register_password_manager_hooks();
|
||||
$this->register_password_strength_meter_hooks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the account protection module is enabled on the site.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_enabled(): bool {
|
||||
return $this->modules->is_active( self::ACCOUNT_PROTECTION_MODULE_NAME );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables the account protection module.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function enable(): bool {
|
||||
// Return true if already enabled.
|
||||
if ( $this->is_enabled() ) {
|
||||
return true;
|
||||
}
|
||||
return $this->modules->activate( self::ACCOUNT_PROTECTION_MODULE_NAME, false, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the account protection module.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function disable(): bool {
|
||||
// Return true if already disabled.
|
||||
if ( ! $this->is_enabled() ) {
|
||||
return true;
|
||||
}
|
||||
return $this->modules->deactivate( self::ACCOUNT_PROTECTION_MODULE_NAME );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if Account Protection is supported in the current environment.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_supported_environment(): bool {
|
||||
// Do not run when killswitch is enabled
|
||||
if ( defined( 'DISABLE_JETPACK_ACCOUNT_PROTECTION' ) && DISABLE_JETPACK_ACCOUNT_PROTECTION ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do not run for WordPress.com Simple sites
|
||||
if ( ( new Host() )->is_wpcom_simple() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current Jetpack version is supported.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function has_unsupported_jetpack_version(): bool {
|
||||
// Do not run when Jetpack version is less than 14.5
|
||||
$jetpack_version = Constants::get_constant( 'JETPACK__VERSION' );
|
||||
if ( $jetpack_version && version_compare( $jetpack_version, '14.5', '<' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the Account Protection module when on an unsupported platform in Jetpack.
|
||||
*
|
||||
* @param array $modules Filterable value for `jetpack_get_available_modules`.
|
||||
*
|
||||
* @return array Array of module slugs.
|
||||
*/
|
||||
public function remove_module_on_unsupported_environments( array $modules ): array {
|
||||
if ( ! $this->is_supported_environment() ) {
|
||||
// Account protection should never be available on unsupported platforms.
|
||||
unset( $modules[ self::ACCOUNT_PROTECTION_MODULE_NAME ] );
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the Account Protection module when on an unsupported platform in a standalone plugin.
|
||||
*
|
||||
* @param array $modules Filterable value for `jetpack_get_available_standalone_modules`.
|
||||
*
|
||||
* @return array Array of module slugs.
|
||||
*/
|
||||
public function remove_standalone_module_on_unsupported_environments( array $modules ): array {
|
||||
if ( ! $this->is_supported_environment() ) {
|
||||
// Account Protection should never be available on unsupported platforms.
|
||||
$modules = array_filter(
|
||||
$modules,
|
||||
function ( $module ) {
|
||||
return $module !== self::ACCOUNT_PROTECTION_MODULE_NAME;
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Config.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
/**
|
||||
* Class Config
|
||||
*/
|
||||
class Config {
|
||||
public const PREFIX = 'jetpack_account_protection';
|
||||
public const SUPPORT_LINK = 'https://jetpack.com/?post_type=jetpack_support&p=324199';
|
||||
public const RECENT_PASSWORD_HASHES_USER_META_KEY = self::PREFIX . '_recent_password_hashes';
|
||||
|
||||
// Password Detection Constants
|
||||
public const PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION = 600; // 10 minutes
|
||||
public const PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT = 4;
|
||||
|
||||
// Password Manager Constants
|
||||
public const PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT = 10;
|
||||
|
||||
// Validation Service Constants
|
||||
public const VALIDATION_SERVICE_MIN_LENGTH = 6;
|
||||
public const VALIDATION_SERVICE_MAX_LENGTH = 150;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Email Service.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Jetpack_Options;
|
||||
|
||||
/**
|
||||
* Class Email_Service
|
||||
*/
|
||||
class Email_Service {
|
||||
/**
|
||||
* Connection manager dependency.
|
||||
*
|
||||
* @var Connection_Manager
|
||||
*/
|
||||
private $connection_manager;
|
||||
|
||||
/**
|
||||
* Constructor for dependency injection.
|
||||
*
|
||||
* @param Connection_Manager|null $connection_manager Connection manager dependency.
|
||||
*/
|
||||
public function __construct(
|
||||
?Connection_Manager $connection_manager = null
|
||||
) {
|
||||
$this->connection_manager = $connection_manager ?? new Connection_Manager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email using the API.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $auth_code The authentication code.
|
||||
*
|
||||
* @return true|\WP_Error True if the email was sent successfully, \WP_Error otherwise.
|
||||
*/
|
||||
public function api_send_auth_email( int $user_id, string $auth_code ) {
|
||||
$blog_id = Jetpack_Options::get_option( 'id' );
|
||||
|
||||
/**
|
||||
* Filters whether the Account Protection verification email should be handled externally.
|
||||
*
|
||||
* When the filter returns a truthy value, the default WPCOM API email send is skipped,
|
||||
* allowing sites to deliver the email locally (e.g. via `wp_mail()`).
|
||||
*
|
||||
* @since 0.3.0
|
||||
*
|
||||
* @param bool $handled Whether the email has been handled. Default false.
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $auth_code The authentication code.
|
||||
* @param int $blog_id The blog ID, or false if not available.
|
||||
*/
|
||||
$handled = apply_filters( 'jetpack_account_protection_send_auth_email', false, $user_id, $auth_code, $blog_id );
|
||||
|
||||
if ( $handled ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( ! $blog_id || ! $this->connection_manager->is_connected() ) {
|
||||
return new \WP_Error( 'jetpack_connection_error', __( 'Jetpack is not connected. Please connect and try again.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
$body = array(
|
||||
'user_id' => $user_id,
|
||||
'code' => $auth_code,
|
||||
);
|
||||
|
||||
$response = $this->send_email_request( (int) $blog_id, $body );
|
||||
if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
|
||||
return new \WP_Error( 'email_send_error', __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( 200 !== $response_code ) {
|
||||
return new \WP_Error( $body['code'] ?? 'email_send_error', $body['message'] ?? __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
if ( empty( $body['email_send_success'] ) ) {
|
||||
return new \WP_Error( 'email_send_error', __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency decoupling for the static call to the client.
|
||||
*
|
||||
* @param int $blog_id Blog ID.
|
||||
* @param array $body The request body.
|
||||
* @return array|\WP_Error Response data or error.
|
||||
*/
|
||||
protected function send_email_request( int $blog_id, array $body ) {
|
||||
return Client::wpcom_json_api_request_as_blog(
|
||||
sprintf( '/sites/%d/jetpack-protect-send-verification-code', $blog_id ),
|
||||
'2',
|
||||
array(
|
||||
'method' => 'POST',
|
||||
),
|
||||
$body,
|
||||
'wpcom'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend email attempts.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $transient_data The transient data.
|
||||
* @param string $token The token.
|
||||
*
|
||||
* @return true|\WP_Error True if the email was resent successfully, \WP_Error otherwise.
|
||||
*/
|
||||
public function resend_auth_email( int $user_id, array $transient_data, string $token ) {
|
||||
if ( $transient_data['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
|
||||
return new \WP_Error( 'email_request_limit_exceeded', __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
$auth_code = $this->generate_auth_code();
|
||||
$transient_data['auth_code'] = $auth_code;
|
||||
|
||||
$resend = $this->api_send_auth_email( $user_id, $auth_code );
|
||||
if ( is_wp_error( $resend ) ) {
|
||||
return $resend;
|
||||
}
|
||||
|
||||
++$transient_data['requests'];
|
||||
|
||||
if ( ! set_transient( Config::PREFIX . "_{$token}", $transient_data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
|
||||
return new \WP_Error( 'transient_set_error', __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an auth code.
|
||||
*
|
||||
* @return string The generated auth code.
|
||||
*/
|
||||
public function generate_auth_code(): string {
|
||||
return (string) wp_rand( 100000, 999999 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an email address like d*****@g*****.com.
|
||||
*
|
||||
* @param string $email The email address to mask.
|
||||
*
|
||||
* @return string The masked email address.
|
||||
*/
|
||||
public function mask_email_address( string $email ): string {
|
||||
$parts = explode( '@', $email );
|
||||
$name = substr( $parts[0], 0, 1 ) . str_repeat( '*', strlen( $parts[0] ) - 1 );
|
||||
$domain_parts = explode( '.', $parts[1] );
|
||||
$domain = substr( $domain_parts[0], 0, 1 ) . str_repeat( '*', strlen( $domain_parts[0] ) - 1 );
|
||||
|
||||
// Join all domain parts except the first one with dots
|
||||
$tld = implode( '.', array_slice( $domain_parts, 1 ) );
|
||||
|
||||
return "{$name}@{$domain}.{$tld}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Password Detection.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
use Automattic\Jetpack\Assets\Logo as Jetpack_Logo;
|
||||
|
||||
/**
|
||||
* Class Password_Detection
|
||||
*/
|
||||
class Password_Detection {
|
||||
/**
|
||||
* Email service dependency.
|
||||
*
|
||||
* @var Email_Service
|
||||
*/
|
||||
private $email_service;
|
||||
|
||||
/**
|
||||
* Validation service dependency.
|
||||
*
|
||||
* @var Validation_Service
|
||||
*/
|
||||
private $validation_service;
|
||||
|
||||
/**
|
||||
* Password_Detection constructor.
|
||||
*
|
||||
* @param ?Email_Service $email_service Email service instance.
|
||||
* @param ?Validation_Service $validation_service Validation service instance.
|
||||
*/
|
||||
public function __construct( ?Email_Service $email_service = null, ?Validation_Service $validation_service = null ) {
|
||||
$this->email_service = $email_service ?? new Email_Service();
|
||||
$this->validation_service = $validation_service ?? new Validation_Service();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password is safe after login.
|
||||
*
|
||||
* @param \WP_User|\WP_Error|null $user The user or error object, or null.
|
||||
* @param string|null $password The password.
|
||||
*
|
||||
* @return \WP_User|\WP_Error|null The user object, error object, or null.
|
||||
*/
|
||||
public function login_form_password_detection( $user, ?string $password ) {
|
||||
// First check if the user object and password are valid. Third-party plugins might pass
|
||||
// incompatible types to authentication hooks, so we need this extra check.
|
||||
if ( is_wp_error( $user ) || ! ( $user instanceof \WP_User ) || $password === null ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if ( ! $this->user_requires_protection( $user, $password ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
// Skip if we're validating a Brute force protection recovery token
|
||||
if ( get_transient( 'jetpack_protect_recovery_key_validated_' . $user->ID ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if ( ! $this->validation_service->is_leaked_password( $password ) ) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$auth_code = $this->email_service->generate_auth_code();
|
||||
$existing_transient_token = get_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
|
||||
$existing_transient = $existing_transient_token ? get_transient( Config::PREFIX . "_{$existing_transient_token}" ) : null;
|
||||
|
||||
if ( $existing_transient && isset( $existing_transient['requests'] ) &&
|
||||
$existing_transient['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
|
||||
|
||||
// Resend limit reached, prevent sending new email
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'email_request_limit_exceeded',
|
||||
'message' => __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
|
||||
$this->redirect_and_exit( $this->get_redirect_url( $existing_transient_token ) );
|
||||
|
||||
}
|
||||
|
||||
$email_sent = $this->email_service->api_send_auth_email( $user->ID, $auth_code );
|
||||
|
||||
if ( is_wp_error( $email_sent ) ) {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => $email_sent->get_error_code(),
|
||||
'message' => $email_sent->get_error_message(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$new_transient_token = null;
|
||||
|
||||
// Update or create a transient token
|
||||
if ( $existing_transient ) {
|
||||
if ( ! is_wp_error( $email_sent ) ) {
|
||||
$existing_transient['auth_code'] = $auth_code;
|
||||
$existing_transient['requests'] = ( $existing_transient['requests'] ?? 0 ) + 1;
|
||||
|
||||
if ( ! set_transient( Config::PREFIX . "_{$existing_transient_token}", $existing_transient, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'transient_error',
|
||||
'message' => __( 'Failed to update authentication token. Please try again.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$new_transient_token = $this->generate_and_store_transient_data( $user->ID, $auth_code );
|
||||
}
|
||||
|
||||
$this->redirect_and_exit( $this->get_redirect_url( $new_transient_token ? $new_transient_token : $existing_transient_token ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect and exit.
|
||||
*
|
||||
* @param string $redirect_location The redirect location.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
protected function redirect_and_exit( string $redirect_location ) {
|
||||
wp_safe_redirect( $redirect_location );
|
||||
$this->exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit decoupling.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
protected function exit() {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user by ID. Dependency decoupling.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
*
|
||||
* @return \WP_User|null The user object.
|
||||
*/
|
||||
protected function load_user( int $user_id ) {
|
||||
return get_user_by( 'ID', $user_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password detection page.
|
||||
*/
|
||||
public function render_page() {
|
||||
if ( is_user_logged_in() ) {
|
||||
$this->redirect_and_exit( admin_url() );
|
||||
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
|
||||
return;
|
||||
}
|
||||
|
||||
$token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : null;
|
||||
$transient_data = get_transient( Config::PREFIX . "_{$token}" );
|
||||
if ( ! $transient_data ) {
|
||||
$this->redirect_to_login();
|
||||
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = $transient_data['user_id'] ?? null;
|
||||
$user = $user_id ? $this->load_user( (int) $user_id ) : null;
|
||||
if ( ! $user instanceof \WP_User ) {
|
||||
$this->redirect_to_login();
|
||||
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle resend email request
|
||||
if ( isset( $_GET['resend_email'] ) && $_GET['resend_email'] === '1' ) {
|
||||
if ( isset( $_GET['_wpnonce'] )
|
||||
&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'resend_email_nonce' )
|
||||
) {
|
||||
$email_resent = $this->email_service->resend_auth_email( $user->ID, $transient_data, $token );
|
||||
if ( is_wp_error( $email_resent ) ) {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => $email_resent->get_error_code(),
|
||||
'message' => $email_resent->get_error_message(),
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$this->set_transient_success(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'email_resend_success',
|
||||
'message' => __( 'Authentication email resent successfully.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$this->redirect_and_exit( $this->get_redirect_url( $token ) );
|
||||
// @phan-suppress-next-line PhanPluginUnreachableCode This would fall through in unit tests otherwise.
|
||||
return;
|
||||
} else {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'email_resend_nonce_error',
|
||||
'message' => __( 'Resend nonce verification failed. Please try again.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle verify form submission
|
||||
if ( isset( $_POST['verify'] ) ) {
|
||||
if ( ! empty( $_POST['_wpnonce_verify'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce_verify'] ) ), 'verify_action' ) ) {
|
||||
$user_input = isset( $_POST['user_input'] ) ? sanitize_text_field( wp_unslash( $_POST['user_input'] ) ) : null;
|
||||
|
||||
$this->handle_auth_form_submission( $user, $token, $transient_data['auth_code'] ?? null, $user_input );
|
||||
} else {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'verify_nonce_error',
|
||||
'message' => __( 'Verify nonce verification failed. Please try again.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->render_content( $user, $token );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract transient data safely and delete the transient.
|
||||
*
|
||||
* @param string $transient_key The transient key.
|
||||
* @return array An array containing 'message' and 'code'.
|
||||
*/
|
||||
public function extract_and_clear_transient_data( string $transient_key ): array {
|
||||
$data = get_transient( $transient_key );
|
||||
delete_transient( $transient_key );
|
||||
|
||||
return array(
|
||||
'message' => $data['message'] ?? null,
|
||||
'code' => $data['code'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content for password detection page.
|
||||
*
|
||||
* @param \WP_User $user The user.
|
||||
* @param string $token The token.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_content( \WP_User $user, string $token ): void {
|
||||
$error_transient_key = Config::PREFIX . "_error_{$user->ID}";
|
||||
$success_transient_key = Config::PREFIX . "_success_{$user->ID}";
|
||||
|
||||
$error_data = $this->extract_and_clear_transient_data( $error_transient_key );
|
||||
$success_data = $this->extract_and_clear_transient_data( $success_transient_key );
|
||||
|
||||
$body_classes = 'password-detection-wrapper';
|
||||
if ( 'auth_code_success' === $success_data['code'] ) {
|
||||
$body_classes .= ' interim-login-success';
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?php esc_html_e( 'Jetpack - Secure Your Account', 'jetpack-account-protection' ); ?></title>
|
||||
<?php wp_head(); ?>
|
||||
</head>
|
||||
<body class="<?php echo esc_attr( $body_classes ); ?>">
|
||||
<div class="password-detection-content">
|
||||
<?php
|
||||
$jetpack_logo = new Jetpack_Logo();
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $jetpack_logo->get_jp_emblem( true );
|
||||
?>
|
||||
<p class="password-detection-title"><?php echo $success_data['code'] === 'auth_code_success' ? esc_html__( 'Take action to stay secure', 'jetpack-account-protection' ) : esc_html__( 'Verify your identity', 'jetpack-account-protection' ); ?></p>
|
||||
<?php if ( $error_data['message'] ) : ?>
|
||||
<div class="error notice">
|
||||
<p class="notice-message"><?php echo esc_html( $error_data['message'] ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ( $success_data['message'] ) : ?>
|
||||
<div class="success notice">
|
||||
<p class="notice-message"><?php echo esc_html( $success_data['message'] ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ( $success_data['code'] === 'auth_code_success' ) : ?>
|
||||
<p><?php esc_html_e( "You're all set! You can now access your account.", 'jetpack-account-protection' ); ?></p>
|
||||
<p><?php esc_html_e( 'Please keep in mind that your current password was found in a public leak, which means your account might be at risk. It is highly recommended that you update your password.', 'jetpack-account-protection' ); ?></p>
|
||||
<div class="actions">
|
||||
<a href="<?php echo esc_url( admin_url( 'profile.php#password' ) ); ?>" class="action action-update-password">
|
||||
<?php esc_html_e( 'Create a new password', 'jetpack-account-protection' ); ?>
|
||||
</a>
|
||||
<a href="<?php echo esc_url( admin_url() ); ?>" class="action action-proceed">
|
||||
<?php esc_html_e( 'Proceed without updating', 'jetpack-account-protection' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: Risks of using weak passwords link */
|
||||
esc_html__( 'Learn more about the %s and how to protect your account.', 'jetpack-account-protection' ),
|
||||
'<a class="risks-link" href="' . esc_url( Config::SUPPORT_LINK . '#risks-of-using-a-weak-password' ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'risks of using weak passwords', 'jetpack-account-protection' ) . '</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( 'We\'ve noticed that your current password may have been compromised in a public leak. To keep your account safe, we\'ve added an extra layer of security.', 'jetpack-account-protection' ); ?></p>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: Masked email address */
|
||||
esc_html__( 'We\'ve sent a code to %s. Please check your inbox and enter the code below to verify it\'s really you.', 'jetpack-account-protection' ),
|
||||
esc_html( $this->email_service->mask_email_address( $user->user_email ) )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<div class="actions">
|
||||
<form method="post">
|
||||
<?php wp_nonce_field( 'verify_action', '_wpnonce_verify' ); ?>
|
||||
<input
|
||||
type="text"
|
||||
name="user_input"
|
||||
class="action-input"
|
||||
placeholder="<?php esc_attr_e( 'Enter verification code', 'jetpack-account-protection' ); ?>"
|
||||
required
|
||||
pattern="\d{6}"
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
oninput="this.value = this.value.replace(/\D/g, '');"
|
||||
/>
|
||||
<button class="action action-verify" type="submit" name="verify"><?php esc_html_e( 'Verify', 'jetpack-account-protection' ); ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<?php if ( in_array( $error_data['code'], array( 'email_request_limit_exceeded', 'email_send_error' ), true ) ) : ?>
|
||||
<p class="account-recovery">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: Jetpack support link */
|
||||
esc_html__( 'If you did not receive your authentication code, please try again later or %s now.', 'jetpack-account-protection' ),
|
||||
'<a class="risks-link" href="' . esc_url( wp_lostpassword_url() ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'reset your password', 'jetpack-account-protection' ) . '</a>'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p class="email-status">
|
||||
<span><?php esc_html_e( "Didn't get the code?", 'jetpack-account-protection' ); ?> </span>
|
||||
<a class="resend-email-link" href="<?php echo esc_url( $this->get_redirect_url( $token ) . '&resend_email=1&_wpnonce=' . wp_create_nonce( 'resend_email_nonce' ) ); ?>">
|
||||
<?php esc_html_e( 'Resend email', 'jetpack-account-protection' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php wp_footer(); ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
$this->exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user requires password protection.
|
||||
*
|
||||
* @param \WP_User $user The user object.
|
||||
* @param string $password The password.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function user_requires_protection( \WP_User $user, string $password ): bool {
|
||||
if ( ! user_can( $user, 'publish_posts' ) && ! user_can( $user, 'edit_published_posts' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter which determines whether or not password detection should be applied for the provided user.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*
|
||||
* @param bool $requires_protection Whether or not password detection should be applied.
|
||||
* @param \WP_User $user The user object to apply the filter against.
|
||||
*/
|
||||
|
||||
$user_requires_protection = apply_filters( 'jetpack_account_protection_user_requires_protection', true, $user );
|
||||
|
||||
if ( ! $user_requires_protection ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return wp_check_password( $password, $user->user_pass, $user->ID );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and store a consolidated transient for the user.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $auth_code The auth code.
|
||||
*
|
||||
* @return string The generated token associated with the new transient data.
|
||||
*/
|
||||
private function generate_and_store_transient_data( int $user_id, string $auth_code ): string {
|
||||
$token = wp_generate_password( 32, false, false );
|
||||
|
||||
$data = array(
|
||||
'user_id' => $user_id,
|
||||
'auth_code' => $auth_code,
|
||||
'requests' => 1,
|
||||
);
|
||||
|
||||
$set_token_transient = set_transient( Config::PREFIX . "_{$token}", $data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
|
||||
$set_user_transient = set_transient( Config::PREFIX . "_last_valid_token_{$user_id}", $token, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
|
||||
if ( ! $set_token_transient || ! $set_user_transient ) {
|
||||
$this->set_transient_error(
|
||||
$user_id,
|
||||
array(
|
||||
'code' => 'transient_error',
|
||||
'message' => __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to the login page.
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function redirect_to_login() {
|
||||
$this->redirect_and_exit( wp_login_url() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect URL.
|
||||
*
|
||||
* @param string $token The token.
|
||||
*
|
||||
* @return string The redirect URL.
|
||||
*/
|
||||
private function get_redirect_url( string $token ): string {
|
||||
return home_url( '/wp-login.php?action=password-detection&token=' . $token );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auth form submission.
|
||||
*
|
||||
* @param \WP_User $user The current user.
|
||||
* @param string $token The token.
|
||||
* @param string $auth_code The expected auth code.
|
||||
* @param string $user_input The user input.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function handle_auth_form_submission( \WP_User $user, string $token, string $auth_code, string $user_input ): void {
|
||||
if ( $auth_code && $auth_code === $user_input ) {
|
||||
$this->set_transient_success(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'auth_code_success',
|
||||
'message' => __( 'Authentication code verified successfully.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
|
||||
delete_transient( Config::PREFIX . "_{$token}" );
|
||||
delete_transient( Config::PREFIX . "_last_valid_token_{$user->ID}" );
|
||||
wp_set_auth_cookie( $user->ID, true );
|
||||
wp_set_current_user( $user->ID );
|
||||
} else {
|
||||
$this->set_transient_error(
|
||||
$user->ID,
|
||||
array(
|
||||
'code' => 'auth_code_error',
|
||||
'message' => __( 'Authentication code verification failed. Please try again.', 'jetpack-account-protection' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a transient success message.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $success An array of the success code and message.
|
||||
* @param int $expiration The expiration time in seconds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set_transient_success( int $user_id, array $success, int $expiration = 60 ): void {
|
||||
set_transient( Config::PREFIX . "_success_{$user_id}", $success, $expiration );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a transient error message.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param array $error An array of the error code and message.
|
||||
* @param int $expiration The expiration time in seconds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function set_transient_error( int $user_id, array $error, int $expiration = 60 ): void {
|
||||
set_transient( Config::PREFIX . "_error_{$user_id}", $error, $expiration );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the password detection page styles.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_styles(): void {
|
||||
global $pagenow;
|
||||
if ( ! isset( $pagenow ) || $pagenow !== 'wp-login.php' ) {
|
||||
return;
|
||||
}
|
||||
// No nonce verification necessary - reading only
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( isset( $_GET['action'] ) && $_GET['action'] === 'password-detection' ) {
|
||||
wp_enqueue_style(
|
||||
'password-detection-styles',
|
||||
plugin_dir_url( __FILE__ ) . 'css/password-detection.css',
|
||||
array(),
|
||||
Account_Protection::PACKAGE_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Password Manager.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
/**
|
||||
* Class Password_Manager
|
||||
*/
|
||||
class Password_Manager {
|
||||
/**
|
||||
* Validaton service instance
|
||||
*
|
||||
* @var Validation_Service
|
||||
*/
|
||||
private $validation_service;
|
||||
|
||||
/**
|
||||
* Validation_Service constructor.
|
||||
*
|
||||
* @param ?Validation_Service $validation_service Password manager instance.
|
||||
*/
|
||||
public function __construct( ?Validation_Service $validation_service = null ) {
|
||||
$this->validation_service = $validation_service ?? new Validation_Service();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the profile update.
|
||||
*
|
||||
* @param \WP_Error $errors The error object.
|
||||
* @param bool $update Whether the user is being updated.
|
||||
* @param \stdClass $user A copy of the new user object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validate_profile_update( \WP_Error $errors, bool $update, \stdClass $user ): void {
|
||||
if ( empty( $user->user_pass ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If bypass is enabled, do not validate the password
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$core_validation_errors = $errors->get_error_messages( 'pass' );
|
||||
$jetpack_validation_errors = $this->validation_service->get_validation_errors( $user->user_pass, true, $user );
|
||||
$validation_errors = array_diff( $jetpack_validation_errors, $core_validation_errors );
|
||||
|
||||
foreach ( $validation_errors as $validation_error ) {
|
||||
$errors->add( 'pass', $validation_error, array( 'form-field' => 'pass1' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the password reset.
|
||||
*
|
||||
* @param \WP_Error $errors The error object.
|
||||
* @param \WP_User|\WP_Error $user The user object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validate_password_reset( \WP_Error $errors, $user ): void {
|
||||
if ( is_wp_error( $user ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( empty( $_POST['pass1'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If bypass is enabled, do not validate the password
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( isset( $_POST['pw_weak'] ) && 'on' === $_POST['pw_weak'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$password = wp_unslash( $_POST['pass1'] );
|
||||
|
||||
$core_validation_errors = $errors->get_error_messages( 'pass' );
|
||||
$jetpack_validation_errors = $this->validation_service->get_validation_errors( $password );
|
||||
$validation_errors = array_diff( $jetpack_validation_errors, $core_validation_errors );
|
||||
|
||||
foreach ( $validation_errors as $validation_error ) {
|
||||
$errors->add( 'pass', $validation_error, array( 'form-field' => 'pass1' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the profile update.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param \WP_User|\stdClass|null $old_user_data Object containing user data prior to update.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_profile_update( int $user_id, $old_user_data ): void {
|
||||
if ( ! is_object( $old_user_data ) || empty( $old_user_data->user_pass ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( isset( $_POST['action'] ) && $_POST['action'] === 'update' ) {
|
||||
$this->save_recent_password_hash( $user_id, $old_user_data->user_pass );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the password reset.
|
||||
*
|
||||
* @param \WP_User|\stdClass|null $user The user object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_password_reset( $user ): void {
|
||||
if ( ! is_object( $user ) || ! isset( $user->ID ) || empty( $user->user_pass ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->save_recent_password_hash( $user->ID, $user->user_pass );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the new password hash to the user's recent passwords list.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $password_hash The password hash to store.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function save_recent_password_hash( int $user_id, string $password_hash ): void {
|
||||
$recent_passwords = get_user_meta( $user_id, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, true );
|
||||
|
||||
if ( ! is_array( $recent_passwords ) ) {
|
||||
$recent_passwords = array();
|
||||
}
|
||||
|
||||
if ( in_array( $password_hash, $recent_passwords, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new hashed password and keep only the last 10
|
||||
array_unshift( $recent_passwords, $password_hash );
|
||||
$recent_passwords = array_slice( $recent_passwords, 0, Config::PASSWORD_MANAGER_RECENT_PASSWORDS_LIMIT );
|
||||
|
||||
update_user_meta( $user_id, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, $recent_passwords );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Password Strength Meter.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
use Automattic\Jetpack\Assets\Logo as Jetpack_Logo;
|
||||
|
||||
/**
|
||||
* Class Password_Strength_Meter
|
||||
*/
|
||||
class Password_Strength_Meter {
|
||||
/**
|
||||
* Validaton service instance
|
||||
*
|
||||
* @var Validation_Service
|
||||
*/
|
||||
private $validation_service;
|
||||
|
||||
/**
|
||||
* Validation_Service constructor.
|
||||
*
|
||||
* @param ?Validation_Service $validation_service Password manager instance.
|
||||
*/
|
||||
public function __construct( ?Validation_Service $validation_service = null ) {
|
||||
$this->validation_service = $validation_service ?? new Validation_Service();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method for nonce verification.
|
||||
*
|
||||
* @param string $nonce Nonce value.
|
||||
* @param string $action Nonce action.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function verify_nonce( string $nonce, string $action ): bool {
|
||||
return wp_verify_nonce( $nonce, $action );
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method for sending a JSON error response.
|
||||
*
|
||||
* @param string $message The error message.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function send_json_error( string $message ): void {
|
||||
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
|
||||
wp_send_json_error( array( 'message' => $message ), null, JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method for sending a JSON success response.
|
||||
*
|
||||
* @param array $data The data to send.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function send_json_success( array $data ): void {
|
||||
// @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
|
||||
wp_send_json_success( $data, null, JSON_UNESCAPED_SLASHES );
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint for password validation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function validate_password_ajax(): void {
|
||||
// phpcs:disable WordPress.Security.NonceVerification
|
||||
if ( ! isset( $_POST['password'] ) ) {
|
||||
$this->send_json_error( __( 'No password provided.', 'jetpack-account-protection' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! isset( $_POST['nonce'] ) || ! $this->verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'validate_password_nonce' ) ) {
|
||||
$this->send_json_error( __( 'Invalid nonce.', 'jetpack-account-protection' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$user_specific = false;
|
||||
if ( isset( $_POST['user_specific'] ) ) {
|
||||
$user_specific = filter_var( sanitize_text_field( wp_unslash( $_POST['user_specific'] ) ), FILTER_VALIDATE_BOOLEAN );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
|
||||
$password = wp_unslash( $_POST['password'] );
|
||||
// phpcs:enable WordPress.Security.NonceVerification
|
||||
$state = $this->validation_service->get_validation_state( $password, $user_specific );
|
||||
|
||||
$this->send_json_success( array( 'state' => $state ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the password strength meter script on the profile page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_jetpack_password_strength_meter_profile_script(): void {
|
||||
global $pagenow;
|
||||
|
||||
if ( ! isset( $pagenow ) || ! in_array( $pagenow, array( 'profile.php', 'user-new.php', 'user-edit.php' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->enqueue_script();
|
||||
$this->enqueue_styles();
|
||||
|
||||
// Only profile page should run user specific checks.
|
||||
$this->localize_jetpack_data( 'profile.php' === $pagenow );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the password strength meter script on the reset password page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_jetpack_password_strength_meter_reset_script(): void {
|
||||
// No nonce verification necessary as the action includes a robust verification process
|
||||
// phpcs:ignore WordPress.Security.NonceVerification
|
||||
if ( isset( $_GET['action'] ) && ( 'rp' === $_GET['action'] || 'resetpass' === $_GET['action'] ) ) {
|
||||
$this->enqueue_script();
|
||||
$this->enqueue_styles();
|
||||
$this->localize_jetpack_data();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize the Jetpack data for the password strength meter.
|
||||
*
|
||||
* @param bool $user_specific Whether or not to run user specific checks.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function localize_jetpack_data( bool $user_specific = false ): void {
|
||||
$jetpack_logo = new Jetpack_Logo();
|
||||
|
||||
wp_localize_script(
|
||||
'jetpack-password-strength-meter',
|
||||
'jetpackData',
|
||||
array(
|
||||
'ajaxurl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'validate_password_nonce' ),
|
||||
'userSpecific' => $user_specific,
|
||||
'logo' => htmlspecialchars( $jetpack_logo->get_jp_emblem( true ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ),
|
||||
'validationInitialState' => $this->validation_service->get_validation_initial_state( $user_specific ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the password strength meter script.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_script(): void {
|
||||
wp_enqueue_script(
|
||||
'jetpack-password-strength-meter',
|
||||
plugin_dir_url( __FILE__ ) . 'js/jetpack-password-strength-meter.js',
|
||||
array( 'jquery' ),
|
||||
Account_Protection::PACKAGE_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the password strength meter styles.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function enqueue_styles(): void {
|
||||
wp_enqueue_style(
|
||||
'strength-meter-styles',
|
||||
plugin_dir_url( __FILE__ ) . 'css/strength-meter.css',
|
||||
array(),
|
||||
Account_Protection::PACKAGE_VERSION
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to manage settings related to Account Protection.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
/**
|
||||
* Account Protection Settings
|
||||
*/
|
||||
class Settings {
|
||||
/**
|
||||
* Account protection instance.
|
||||
*
|
||||
* @var Account_Protection
|
||||
*/
|
||||
private $account_protection;
|
||||
|
||||
/**
|
||||
* Constructor for dependency injection.
|
||||
*
|
||||
* @param ?Account_Protection|null $account_protection Account protection dependency.
|
||||
*/
|
||||
public function __construct( ?Account_Protection $account_protection = null ) {
|
||||
$this->account_protection = $account_protection ?? Account_Protection::instance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account protection settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get() {
|
||||
$settings = array(
|
||||
'isEnabled' => $this->account_protection->is_enabled(),
|
||||
'isSupported' => $this->account_protection->is_supported_environment(),
|
||||
'hasUnsupportedJetpackVersion' => $this->account_protection->has_unsupported_jetpack_version(),
|
||||
);
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
/**
|
||||
* Class used to define Validation Service.
|
||||
*
|
||||
* @package automattic/jetpack-account-protection
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Account_Protection;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
|
||||
/**
|
||||
* Class Validation_Service
|
||||
*/
|
||||
class Validation_Service {
|
||||
|
||||
/**
|
||||
* Connection manager dependency.
|
||||
*
|
||||
* @var Connection_Manager
|
||||
*/
|
||||
private $connection_manager;
|
||||
|
||||
/**
|
||||
* Constructor for dependency injection.
|
||||
*
|
||||
* @param Connection_Manager|null $connection_manager Connection manager dependency.
|
||||
*/
|
||||
public function __construct(
|
||||
?Connection_Manager $connection_manager = null
|
||||
) {
|
||||
$this->connection_manager = $connection_manager ?? new Connection_Manager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency decoupling so we can test this class.
|
||||
*
|
||||
* @param string $password_prefix The password prefix to be checked.
|
||||
* @return array|\WP_Error
|
||||
*/
|
||||
protected function request_suffixes( string $password_prefix ) {
|
||||
return Client::wpcom_json_api_request_as_blog(
|
||||
'/jetpack-protect-weak-password/' . $password_prefix,
|
||||
'2',
|
||||
array( 'method' => 'GET' ),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return validation initial state.
|
||||
*
|
||||
* @param bool $user_specific Whether or not to include user specific checks.
|
||||
*
|
||||
* @return array An array of all validation statuses and messages.
|
||||
*/
|
||||
public function get_validation_initial_state( $user_specific ): array {
|
||||
$base_conditions = array(
|
||||
'core' => array(
|
||||
'status' => null,
|
||||
'message' => __( 'Strong password', 'jetpack-account-protection' ),
|
||||
'info' => __( 'Passwords should meet WordPress core security requirements to enhance account protection.', 'jetpack-account-protection' ),
|
||||
),
|
||||
'contains_backslash' => array(
|
||||
'status' => null,
|
||||
'message' => __( "Doesn't contain a backslash (\\) character", 'jetpack-account-protection' ),
|
||||
'info' => null,
|
||||
),
|
||||
'invalid_length' => array(
|
||||
'status' => null,
|
||||
'message' => sprintf(
|
||||
/* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
|
||||
__( 'Between %1$d and %2$d characters', 'jetpack-account-protection' ),
|
||||
$this->get_min_length(),
|
||||
$this->get_max_length()
|
||||
),
|
||||
'info' => null,
|
||||
),
|
||||
'leaked' => array(
|
||||
'status' => null,
|
||||
'message' => __( 'Not a leaked password', 'jetpack-account-protection' ),
|
||||
'info' => __( 'If found in a public breach, this password may already be known to attackers.', 'jetpack-account-protection' ),
|
||||
),
|
||||
);
|
||||
|
||||
if ( ! $user_specific ) {
|
||||
return $base_conditions;
|
||||
}
|
||||
|
||||
$user_specific_conditions = array(
|
||||
'matches_user_data' => array(
|
||||
'status' => null,
|
||||
'message' => __( "Doesn't match existing user data", 'jetpack-account-protection' ),
|
||||
'info' => __( 'Using a password similar to your username or email makes it easier to guess.', 'jetpack-account-protection' ),
|
||||
),
|
||||
'recent' => array(
|
||||
'status' => null,
|
||||
'message' => __( 'Not used recently', 'jetpack-account-protection' ),
|
||||
'info' => __( 'Reusing old passwords may increase security risks. A fresh password improves protection.', 'jetpack-account-protection' ),
|
||||
),
|
||||
);
|
||||
|
||||
return array_merge( $base_conditions, $user_specific_conditions );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return validation state - client-side.
|
||||
*
|
||||
* @param string $password The password to check.
|
||||
* @param bool $user_specific Whether or not to run user specific checks.
|
||||
*
|
||||
* @return array An array of the status of each check.
|
||||
*/
|
||||
public function get_validation_state( string $password, $user_specific ): array {
|
||||
$validation_state = $this->get_validation_initial_state( $user_specific );
|
||||
|
||||
$validation_state['contains_backslash']['status'] = $this->contains_backslash( $password );
|
||||
$validation_state['invalid_length']['status'] = $this->is_invalid_length( $password );
|
||||
$validation_state['leaked']['status'] = $this->is_leaked_password( $password );
|
||||
|
||||
if ( ! $user_specific ) {
|
||||
return $validation_state;
|
||||
}
|
||||
|
||||
// Run checks on existing user data
|
||||
$user = wp_get_current_user();
|
||||
$validation_state['matches_user_data']['status'] = $this->matches_user_data( $user, $password );
|
||||
$validation_state['recent']['status'] = $this->is_recent_password_hash( $user, $password );
|
||||
|
||||
return $validation_state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all validation errors - server-side.
|
||||
*
|
||||
* @param string $password The password to check.
|
||||
* @param bool $user_specific Whether or not to run user specific checks.
|
||||
* @param \stdClass|null $user The user data or null.
|
||||
*
|
||||
* @return array The validation errors (if any).
|
||||
*/
|
||||
public function get_validation_errors( string $password, $user_specific = false, $user = null ): array {
|
||||
$errors = array();
|
||||
|
||||
if ( empty( $password ) ) {
|
||||
$errors[] = __( '<strong>Error:</strong> The password cannot be a space or all spaces.', 'jetpack-account-protection' );
|
||||
}
|
||||
|
||||
if ( $this->contains_backslash( $password ) ) {
|
||||
$errors[] = __( '<strong>Error:</strong> Passwords may not contain the character "\\".', 'jetpack-account-protection' );
|
||||
}
|
||||
|
||||
if ( $this->is_invalid_length( $password ) ) {
|
||||
$errors[] = sprintf(
|
||||
/* translators: %1$d is the minimum password length, %2$d is the maximum password length. */
|
||||
__( '<strong>Error:</strong> The password must be between %1$d and %2$d characters.', 'jetpack-account-protection' ),
|
||||
$this->get_min_length(),
|
||||
$this->get_max_length()
|
||||
);
|
||||
}
|
||||
|
||||
if ( $this->is_leaked_password( $password ) ) {
|
||||
$errors[] = __( '<strong>Error:</strong> The password was found in a public leak.', 'jetpack-account-protection' );
|
||||
}
|
||||
|
||||
// Skip user-specific checks during password reset
|
||||
if ( $user_specific ) {
|
||||
// Run checks on new user data
|
||||
if ( $this->matches_user_data( $user, $password ) ) {
|
||||
$errors[] = __( '<strong>Error:</strong> The password matches new user data.', 'jetpack-account-protection' );
|
||||
}
|
||||
if ( $this->is_recent_password_hash( $user, $password ) ) {
|
||||
$errors[] = __( '<strong>Error:</strong> The password was used recently.', 'jetpack-account-protection' );
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password contains a backslash.
|
||||
*
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password contains a backslash, false otherwise.
|
||||
*/
|
||||
public function contains_backslash( string $password ): bool {
|
||||
return strpos( $password, '\\' ) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password length is within the allowed range.
|
||||
*
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password is between get_min_length() and get_max_length() characters, false otherwise.
|
||||
*/
|
||||
public function is_invalid_length( string $password ): bool {
|
||||
$length = strlen( $password );
|
||||
return $length < $this->get_min_length() || $length > $this->get_max_length();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum allowed password length.
|
||||
*
|
||||
* @return int The minimum allowed password length.
|
||||
*/
|
||||
public function get_min_length(): int {
|
||||
/**
|
||||
* Filters the minimum allowed password length for Account Protection.
|
||||
*
|
||||
* The default is a floor: values below it are ignored, so the filter can only
|
||||
* raise the minimum, never lower it.
|
||||
*
|
||||
* @since 0.3.4
|
||||
*
|
||||
* @param int $min_length The minimum allowed password length.
|
||||
*/
|
||||
$min_length = (int) apply_filters( 'jetpack_account_protection_validation_min_length', Config::VALIDATION_SERVICE_MIN_LENGTH );
|
||||
return max( Config::VALIDATION_SERVICE_MIN_LENGTH, $min_length );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum allowed password length.
|
||||
*
|
||||
* @return int The maximum allowed password length.
|
||||
*/
|
||||
public function get_max_length(): int {
|
||||
/**
|
||||
* Filters the maximum allowed password length for Account Protection.
|
||||
*
|
||||
* The default is a floor: values below it are ignored, so the filter can only
|
||||
* raise the maximum, never lower it.
|
||||
*
|
||||
* @since 0.3.4
|
||||
*
|
||||
* @param int $max_length The maximum allowed password length.
|
||||
*/
|
||||
$max_length = (int) apply_filters( 'jetpack_account_protection_validation_max_length', Config::VALIDATION_SERVICE_MAX_LENGTH );
|
||||
return max( Config::VALIDATION_SERVICE_MAX_LENGTH, $max_length );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password matches any user data.
|
||||
*
|
||||
* @param \WP_User|\stdClass|null $user The user.
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password matches any user data, false otherwise.
|
||||
*/
|
||||
public function matches_user_data( $user, string $password ): bool {
|
||||
if ( ! $user ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email_parts = explode( '@', $user->user_email ); // test@example.com
|
||||
$email_username = $email_parts[0]; // 'test'
|
||||
$email_domain = $email_parts[1]; // 'example.com'
|
||||
$email_provider = explode( '.', $email_domain )[0]; // 'example'
|
||||
|
||||
$user_data = array(
|
||||
$user->user_login ?? '',
|
||||
$user->display_name ?? '',
|
||||
$user->first_name ?? '',
|
||||
$user->last_name ?? '',
|
||||
$user->user_email ?? '',
|
||||
$email_username ?? '',
|
||||
$email_provider ?? '',
|
||||
$user->nickname ?? '',
|
||||
);
|
||||
|
||||
$password_lower = strtolower( $password );
|
||||
|
||||
foreach ( $user_data as $data ) {
|
||||
// Skip if $data is 3 characters or less.
|
||||
if ( strlen( $data ) <= 3 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! empty( $data ) && strpos( $password_lower, strtolower( $data ) ) !== false ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password is in the list of compromised/common passwords.
|
||||
*
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password is in the list of compromised/common passwords, false otherwise.
|
||||
*/
|
||||
public function is_leaked_password( string $password ): bool {
|
||||
if ( ! $this->connection_manager->is_connected() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hashed_password = sha1( $password );
|
||||
$password_prefix = substr( $hashed_password, 0, 5 );
|
||||
|
||||
$response = $this->request_suffixes( $password_prefix );
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code( $response );
|
||||
|
||||
if ( is_wp_error( $response ) || 200 !== $response_code || empty( $response['body'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
$password_suffix = substr( $hashed_password, 5 );
|
||||
if ( in_array( $password_suffix, $body['compromised'] ?? array(), true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( in_array( $password_suffix, $body['common'] ?? array(), true ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password is the current password for the user.
|
||||
*
|
||||
* @param int $user_id The user ID.
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password is the current password, false otherwise.
|
||||
*/
|
||||
public function is_current_password( int $user_id, string $password ): bool {
|
||||
$user = get_userdata( $user_id );
|
||||
if ( ! $user ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return wp_check_password( $password, $user->user_pass, $user->ID );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the password has been used recently by the user.
|
||||
*
|
||||
* @param \WP_User|\stdClass $user The user data.
|
||||
* @param string $password The password to check.
|
||||
*
|
||||
* @return bool True if the password was recently used, false otherwise.
|
||||
*/
|
||||
public function is_recent_password_hash( $user, string $password ): bool {
|
||||
// Skip on user creation
|
||||
if ( empty( $user->ID ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user_data = $user instanceof \WP_User ? $user : get_userdata( $user->ID );
|
||||
if ( $this->is_current_password( $user_data->ID, $password ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$recent_passwords = get_user_meta( $user->ID, Config::RECENT_PASSWORD_HASHES_USER_META_KEY, true );
|
||||
if ( empty( $recent_passwords ) || ! is_array( $recent_passwords ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $recent_passwords as $old_hashed_password ) {
|
||||
if ( wp_check_password( $password, $old_hashed_password ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.password-detection-wrapper {
|
||||
background-color: #f0f0f1;
|
||||
min-width: 0;
|
||||
margin: 30px;
|
||||
color: #3c434a;
|
||||
font-family: -apple-system, system-ui, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.password-detection-content {
|
||||
background: #fff;
|
||||
max-width: 420px;
|
||||
margin: auto;
|
||||
padding: 26px 24px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c3c4c7;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
|
||||
#jetpack-logo__icon {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.password-detection-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-input {
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-indent: 8px;
|
||||
|
||||
&::placeholder {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-verify,
|
||||
.action-update-password {
|
||||
margin-top: 10px;
|
||||
background-color: #00e;
|
||||
border: 2px solid #00e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-proceed {
|
||||
background-color: #fff;
|
||||
border: 2px solid #00e;
|
||||
color: #00e;
|
||||
}
|
||||
|
||||
a.risks-link,
|
||||
a.resend-email-link {
|
||||
color: #00e;
|
||||
}
|
||||
|
||||
.email-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notice {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notice-message {
|
||||
text-align: center;
|
||||
margin: 7px 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #facfd2;
|
||||
border: 2px solid #e65054;
|
||||
color: #e65054;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d0e6b8;
|
||||
border: 2px solid #069e08;
|
||||
color: #069e08;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#login .wp-pwd,
|
||||
.user-pass1-wrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.validation-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.validation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
|
||||
.validation-icon {
|
||||
display: inline-block;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNzY3Njc2IiBzdHJva2Utd2lkdGg9IjIiIG9wYWNpdHk9IjAuMyIvPgogIDxwYXRoIGQ9Ik0xMiAyYTEwIDEwIDAgMCAxIDAgMjAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzc2NzY3NiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiPgogICAgPGFuaW1hdGVUcmFuc2Zvcm0gYXR0cmlidXRlTmFtZT0idHJhbnNmb3JtIiB0eXBlPSJyb3RhdGUiIGZyb209IjAgMTIgMTIiIHRvPSIzNjAgMTIgMTIiIGR1cj0iMXMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIi8+CiAgPC9wYXRoPgo8L3N2Zz4=);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.check {
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iIzAwODcxMCIvPgogIDxwYXRoIGQ9Ik0xNi43IDcuMWwtNi4zIDguNS0zLjMtMi41LS45IDEuMiA0LjUgMy40TDE3LjkgOHoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPg==);
|
||||
}
|
||||
|
||||
.cross {
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIgZmlsbD0iI0U2NTA1NCIvPgogIDxwYXRoIGQ9Ik04IDhsOCA4TTE2IDhsLTggOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPC9zdmc+);
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
margin-top: 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-popover {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjQ4IiBoZWlnaHQ9IjQ4IiBmaWxsPSIjQTdBQUFEIj4KICA8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptMCAxOGMtNC40MSAwLTgtMy41OS04LThzMy41OS04IDgtOCA4IDMuNTkgOCA4LTMuNTkgOC04IDh6bTEtMTNoLTJ2MmgyVjd6bTAgNGgtMnY2aDJ2LTZ6Ii8+Cjwvc3ZnPg==);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
|
||||
.popover {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
white-space: normal;
|
||||
width: 150px;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popover-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid #333;
|
||||
}
|
||||
|
||||
#your-profile .strength-meter,
|
||||
#createuser .strength-meter {
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.strength-meter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
background-color: #c3c4c7;
|
||||
}
|
||||
|
||||
.strength-meter .strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.branding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.powered-by {
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#jetpack-logo__icon {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/* global jQuery, jetpackData */
|
||||
|
||||
jQuery( document ).ready( function ( $ ) {
|
||||
const UIComponents = {
|
||||
core: {
|
||||
passwordInput: $( '#pass1' ),
|
||||
passwordStrengthResults: $( '#pass-strength-result' ),
|
||||
weakPasswordConfirmation: $( '.pw-weak' ),
|
||||
weakPasswordConfirmationCheckbox: $( '.pw-weak input[type="checkbox"]' ),
|
||||
submitButtons: $( '#submit, #createusersub, #wp-submit' ),
|
||||
},
|
||||
passwordValidationStatus: $( '<div>', { id: 'password-validation-status' } ),
|
||||
validationCheckList: $( '<ul>', { class: 'validation-checklist' } ),
|
||||
strengthMeter: {},
|
||||
validationChecklistItems: {},
|
||||
};
|
||||
|
||||
let currentAjaxRequest = null;
|
||||
|
||||
/**
|
||||
* Apply initial validation UI structure and styling
|
||||
*/
|
||||
function initializeValidationUI() {
|
||||
initializeForm();
|
||||
initializeStrengthMeter();
|
||||
initializeValidationChecklist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and append the initial strength meter state
|
||||
*/
|
||||
function initializeForm() {
|
||||
const { passwordInput, passwordStrengthResults } = UIComponents.core;
|
||||
|
||||
passwordInput.css( {
|
||||
'border-color': '#8C8F94',
|
||||
'border-radius': '4px 4px 0 0',
|
||||
} );
|
||||
|
||||
passwordStrengthResults.hide();
|
||||
passwordInput.after( UIComponents.passwordValidationStatus );
|
||||
UIComponents.passwordValidationStatus.append( UIComponents.validationCheckList );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and append the initial strength meter state
|
||||
*/
|
||||
function initializeStrengthMeter() {
|
||||
const strengthMeterWrapper = $( '<div>', {
|
||||
class: 'strength-meter',
|
||||
'aria-live': 'polite',
|
||||
} );
|
||||
|
||||
const strengthText = $( '<p>', {
|
||||
class: 'strength',
|
||||
text: 'Validating...',
|
||||
} );
|
||||
|
||||
const branding = $( '<div>', { class: 'branding' } )
|
||||
.append( $( '<p>', { class: 'powered-by', text: 'Powered by ' } ) )
|
||||
.append( jetpackData.logo );
|
||||
|
||||
strengthMeterWrapper.append( strengthText, branding );
|
||||
UIComponents.validationCheckList.before( strengthMeterWrapper );
|
||||
|
||||
UIComponents.strengthMeter = {
|
||||
wrapper: strengthMeterWrapper,
|
||||
text: strengthText,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and append the initial validation checklist state
|
||||
*/
|
||||
function initializeValidationChecklist() {
|
||||
Object.entries( jetpackData.validationInitialState ).forEach( ( [ key, value ] ) => {
|
||||
const listItem = $( '<li>', { class: 'validation-item', 'data-key': key } );
|
||||
|
||||
// Hide the core and backslash validation items by default
|
||||
if ( [ 'core', 'contains_backslash' ].includes( key ) ) {
|
||||
listItem.hide();
|
||||
}
|
||||
|
||||
const validationIcon = $( '<span>', {
|
||||
class: 'validation-icon',
|
||||
} );
|
||||
|
||||
const validationMessage = $( '<p>', {
|
||||
text: value.message,
|
||||
class: 'validation-message',
|
||||
} );
|
||||
|
||||
const infoIconPopover = value.info ? createInfoIconPopover( value.info ) : null;
|
||||
listItem.append( validationIcon, validationMessage, infoIconPopover );
|
||||
UIComponents.validationCheckList.append( listItem );
|
||||
|
||||
UIComponents.validationChecklistItems[ key ] = {
|
||||
icon: validationIcon,
|
||||
text: validationMessage,
|
||||
item: listItem,
|
||||
};
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function to limit the number of requests
|
||||
* @param {Function} func - The function to debounce
|
||||
* @param {number} delay - The delay in milliseconds
|
||||
*
|
||||
* @return {Function} - The debounced function
|
||||
*/
|
||||
function debounce( func, delay ) {
|
||||
let timer;
|
||||
return function () {
|
||||
clearTimeout( timer );
|
||||
timer = setTimeout( () => func.apply( this, arguments ), delay );
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind events to the UI components
|
||||
*/
|
||||
function bindEvents() {
|
||||
const { passwordInput } = UIComponents.core;
|
||||
|
||||
passwordInput.on( 'input', debounce( validatePassword, 250 ) );
|
||||
passwordInput.on( 'pwupdate', validatePassword );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the current password input
|
||||
*/
|
||||
function validatePassword() {
|
||||
const { passwordInput, passwordStrengthResults } = UIComponents.core;
|
||||
|
||||
const password = passwordInput.val();
|
||||
|
||||
if ( currentAjaxRequest ) {
|
||||
const oldRequest = currentAjaxRequest;
|
||||
currentAjaxRequest = null;
|
||||
oldRequest.abort();
|
||||
}
|
||||
|
||||
if ( ! password?.trim() ) {
|
||||
updateValidationUI( 'empty' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure core strength meter is hidden
|
||||
passwordStrengthResults.hide();
|
||||
|
||||
updateValidationUI( 'loading' );
|
||||
|
||||
currentAjaxRequest = $.ajax( {
|
||||
url: jetpackData.ajaxurl,
|
||||
type: 'POST',
|
||||
data: {
|
||||
action: 'validate_password_ajax',
|
||||
nonce: jetpackData.nonce,
|
||||
password: password,
|
||||
user_specific: jetpackData.userSpecific,
|
||||
},
|
||||
success: handleValidationResponse,
|
||||
error: handleValidationError,
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the password validation response.
|
||||
* @param {object} response - The response object.
|
||||
*/
|
||||
function handleValidationResponse( response ) {
|
||||
currentAjaxRequest = null;
|
||||
|
||||
if ( response.success ) {
|
||||
updateValidationUI( 'results', response.data.state );
|
||||
} else {
|
||||
restoreCoreStrengthMeter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles validation errors.
|
||||
* @param {object} jqXHR - The jqXHR object.
|
||||
* @param {any} textStatus - The status of the request.
|
||||
*/
|
||||
function handleValidationError( jqXHR, textStatus ) {
|
||||
if ( textStatus !== 'abort' ) {
|
||||
restoreCoreStrengthMeter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the core validation state
|
||||
* @return {boolean} - Whether the core validation failed
|
||||
*/
|
||||
function getCoreValidationState() {
|
||||
const corePasswordStrengthResultsClass =
|
||||
UIComponents.core.passwordStrengthResults.attr( 'class' ) || '';
|
||||
|
||||
return ! (
|
||||
corePasswordStrengthResultsClass.includes( 'strong' ) ||
|
||||
corePasswordStrengthResultsClass.includes( 'good' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Update the validation UI based on the current state
|
||||
* @param {string} state - The current validation state
|
||||
* @param {object} validationState - Object containing validation state
|
||||
*/
|
||||
function updateValidationUI( state, validationState ) {
|
||||
if ( state === 'empty' ) {
|
||||
renderEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
UIComponents.passwordValidationStatus.show();
|
||||
|
||||
if ( state === 'loading' ) {
|
||||
renderLoadingState();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( validationState ) {
|
||||
renderResultsState( validationState );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the empty input state
|
||||
*/
|
||||
function renderEmptyState() {
|
||||
const { weakPasswordConfirmation, passwordInput } = UIComponents.core;
|
||||
|
||||
weakPasswordConfirmation.hide();
|
||||
passwordInput.css( {
|
||||
'border-color': '#8C8F94',
|
||||
'border-radius': '4px',
|
||||
} );
|
||||
|
||||
UIComponents.passwordValidationStatus.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the loading state
|
||||
*/
|
||||
function renderLoadingState() {
|
||||
renderFormLoadingState();
|
||||
renderStrengthMeterLoadingState();
|
||||
renderValidationChecklistLoadingState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the form loading state
|
||||
*/
|
||||
function renderFormLoadingState() {
|
||||
const { submitButtons, passwordInput } = UIComponents.core;
|
||||
|
||||
submitButtons.prop( 'disabled', true );
|
||||
passwordInput.css( {
|
||||
'border-color': '#C3C4C7',
|
||||
'border-radius': '4px 4px 0px 0px',
|
||||
} );
|
||||
|
||||
UIComponents.passwordValidationStatus.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the strength meter loading state
|
||||
*/
|
||||
function renderStrengthMeterLoadingState() {
|
||||
const { wrapper, text } = UIComponents.strengthMeter;
|
||||
|
||||
text.text( 'Validating...' );
|
||||
wrapper.css( 'background-color', '#C3C4C7' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the validation checklist loading state
|
||||
*/
|
||||
function renderValidationChecklistLoadingState() {
|
||||
Object.entries( UIComponents.validationChecklistItems ).forEach( ( [ key, itemData ] ) => {
|
||||
const { icon, text, item } = itemData;
|
||||
|
||||
icon.removeClass( 'check cross' );
|
||||
text.css( { color: '#3C434A' } );
|
||||
|
||||
// Re-hide the core and contains_backslash items
|
||||
if ( [ 'core', 'contains_backslash' ].includes( key ) ) {
|
||||
item.hide();
|
||||
}
|
||||
} );
|
||||
|
||||
UIComponents.strengthMeter.text.text( 'Validating...' );
|
||||
UIComponents.strengthMeter.wrapper.css( 'background-color', '#C3C4C7' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the validation results state
|
||||
* @param {object} validationState - Object containing validation state
|
||||
*/
|
||||
function renderResultsState( validationState ) {
|
||||
validationState.core.status = getCoreValidationState();
|
||||
const isPasswordStrong = Object.values( validationState ).every( item => ! item.status );
|
||||
const color = isPasswordStrong ? '#9DD977' : '#FFABAF';
|
||||
const failedValidationKeys = ! isPasswordStrong
|
||||
? Object.keys( validationState ).filter( key => validationState[ key ].status )
|
||||
: [];
|
||||
|
||||
renderFormResultsState( isPasswordStrong, color );
|
||||
renderStengthMeterResultsState( isPasswordStrong, color );
|
||||
renderValidationChecklistResultsState( failedValidationKeys );
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the form elements based on the current password strength
|
||||
* @param {boolean} isPasswordStrong - Whether the password is strong
|
||||
* @param {string} color - The color to apply to the form elements
|
||||
*/
|
||||
function renderFormResultsState( isPasswordStrong, color ) {
|
||||
const {
|
||||
passwordInput,
|
||||
weakPasswordConfirmation,
|
||||
weakPasswordConfirmationCheckbox,
|
||||
submitButtons,
|
||||
} = UIComponents.core;
|
||||
passwordInput.css( { 'border-color': color, 'border-radius': '4px 4px 0px 0px' } );
|
||||
|
||||
weakPasswordConfirmation.css( 'display', isPasswordStrong ? 'none' : 'table-row' );
|
||||
submitButtons.prop(
|
||||
'disabled',
|
||||
! isPasswordStrong && ! weakPasswordConfirmationCheckbox.prop( 'checked' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Update the strength meter based on the current password strength
|
||||
* @param {boolean} isPasswordStrong - Whether the password is strong
|
||||
* @param {string} color - The color to apply to the strength meter
|
||||
*/
|
||||
function renderStengthMeterResultsState( isPasswordStrong, color ) {
|
||||
const { wrapper, text } = UIComponents.strengthMeter;
|
||||
|
||||
text.text( isPasswordStrong ? 'Strong' : 'Weak' );
|
||||
wrapper.css( 'background-color', color );
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Update the validation checklist based on the failed validation keys
|
||||
* @param {Array} failedValidationKeys - Array containing failed validation keys
|
||||
*/
|
||||
function renderValidationChecklistResultsState( failedValidationKeys ) {
|
||||
Object.entries( UIComponents.validationChecklistItems ).forEach( ( [ key, itemData ] ) => {
|
||||
const { icon, text, item } = itemData;
|
||||
const validationFailed = failedValidationKeys.includes( key );
|
||||
|
||||
icon.attr( 'class', `validation-icon ${ validationFailed ? 'cross' : 'check' }` );
|
||||
text.css( { color: validationFailed ? '#E65054' : '#008710' } );
|
||||
|
||||
// Display the core and backslash validation items they fail
|
||||
if ( [ 'core', 'contains_backslash' ].includes( key ) ) {
|
||||
item.css( 'display', validationFailed ? 'flex' : 'none' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets UI to core strength meter.
|
||||
*/
|
||||
function restoreCoreStrengthMeter() {
|
||||
renderEmptyState();
|
||||
UIComponents.core.passwordStrengthResults.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an info popover element.
|
||||
*
|
||||
* @param {string} infoText - The text to display in the popover.
|
||||
* @return {jQuery} - The info popover element.
|
||||
*/
|
||||
function createInfoIconPopover( infoText ) {
|
||||
const popover = $( '<div>', { text: infoText, class: 'popover' } ).append(
|
||||
$( '<div>', { class: 'popover-arrow' } )
|
||||
);
|
||||
|
||||
const infoIcon = $( '<span>', { class: 'info-icon' } ).hover(
|
||||
() => popover.fadeIn( 200 ),
|
||||
() => popover.fadeOut( 200 )
|
||||
);
|
||||
|
||||
return $( '<div>', { class: 'info-popover' } ).append( infoIcon, popover );
|
||||
}
|
||||
|
||||
initializeValidationUI();
|
||||
bindEvents();
|
||||
} );
|
||||
@@ -0,0 +1,79 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.9] - 2026-06-29
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.1.8] - 2026-06-25
|
||||
### Changed
|
||||
- Update package dependencies. [#49831]
|
||||
|
||||
## [0.1.7] - 2026-06-22
|
||||
### Changed
|
||||
- Update package dependencies. [#49631] [#49691] [#49757]
|
||||
|
||||
## [0.1.6] - 2026-06-15
|
||||
### Changed
|
||||
- Update package dependencies. [#49273] [#49492]
|
||||
|
||||
## [0.1.5] - 2026-06-08
|
||||
### Changed
|
||||
- Update dependencies. [#49354]
|
||||
|
||||
## [0.1.4] - 2026-06-01
|
||||
### Added
|
||||
- Notices: Add notice when the connection state prevents logs from showing. [#48858]
|
||||
|
||||
### Changed
|
||||
- Update package dependencies. [#48404]
|
||||
|
||||
## [0.1.3] - 2026-05-25
|
||||
### Changed
|
||||
- Update package dependencies. [#48405]
|
||||
|
||||
### Fixed
|
||||
- Fix upgrade prompt incorrectly shown to sites entitled to the full activity log. [#49067]
|
||||
|
||||
## [0.1.2] - 2026-05-19
|
||||
### Added
|
||||
- Add a "Performed by" filter for narrowing the log by actor, including MCP agents. Filtering is applied server-side so totals and pagination stay correct. [#48594]
|
||||
|
||||
### Changed
|
||||
- Update dependencies. [#48778]
|
||||
|
||||
## [0.1.1] - 2026-05-11
|
||||
### Changed
|
||||
- Activity Log: Open the Jetpack Cloud Backup restore flow from the "Manage backup" row action instead of showing a disabled placeholder. [#48531]
|
||||
- Activity Log: Refresh the free-tier upsell illustration to match Jetpack's branding. [#48531]
|
||||
- Activity Log: Rename the row action to "Restore backup" so the label matches what clicking it actually does. [#48531]
|
||||
- Components: Use Link from `@wordpress/ui` instead of ExternalLink. [#48529]
|
||||
|
||||
## [0.1.0] - 2026-05-04
|
||||
### Added
|
||||
- Initial release of the Activity Log package: Hosts the Activity Log UI and its REST endpoints directly in WP Admin. [#48244]
|
||||
|
||||
### Changed
|
||||
- Activity Log: Opt into `<AdminPage unwrapped>` so DataViews can fill the bounded content slot and scroll its table body internally. Header, date picker, and DataViews toolbar stay pinned on short viewports. [#48244]
|
||||
|
||||
### Fixed
|
||||
- Activity Log: Default the page to the Table layout, load the upsell-callout stylesheet from the main entry, and surface the disabled toolbar + disabled date-range picker on the free tier with upgrade tooltips. [#48418]
|
||||
|
||||
## 0.1.0-alpha - unreleased
|
||||
|
||||
Initial release.
|
||||
|
||||
[0.1.9]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.8...v0.1.9
|
||||
[0.1.8]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.7...v0.1.8
|
||||
[0.1.7]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.6...v0.1.7
|
||||
[0.1.6]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.5...v0.1.6
|
||||
[0.1.5]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.4...v0.1.5
|
||||
[0.1.4]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.3...v0.1.4
|
||||
[0.1.3]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.2...v0.1.3
|
||||
[0.1.2]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.1...v0.1.2
|
||||
[0.1.1]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.0...v0.1.1
|
||||
[0.1.0]: https://github.com/Automattic/jetpack-activity-log/compare/v0.1.0-alpha...v0.1.0
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg width="399" height="244" viewBox="0 0 399 244" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="399" height="244" fill="#003010"/>
|
||||
<path d="M181.201 141.678C191.745 152.222 208.841 152.222 219.385 141.678C229.929 131.134 229.929 114.038 219.385 103.494C208.841 92.9499 191.745 92.9499 181.201 103.494C170.657 114.038 170.657 131.134 181.201 141.678Z" stroke="url(#paint0_linear_6208_69986)"/>
|
||||
<path d="M172.716 150.163C187.946 165.394 212.64 165.394 227.87 150.163C243.101 134.933 243.101 110.239 227.87 95.0088C212.64 79.7784 187.946 79.7784 172.716 95.0088C157.485 110.239 157.485 134.933 172.716 150.163Z" stroke="url(#paint1_linear_6208_69986)"/>
|
||||
<path d="M164.231 158.648C184.147 178.565 216.439 178.565 236.355 158.648C256.272 138.732 256.272 106.44 236.355 86.5236C216.439 66.6068 184.147 66.6068 164.231 86.5236C144.314 106.44 144.314 138.732 164.231 158.648Z" stroke="url(#paint2_linear_6208_69986)"/>
|
||||
<path d="M155.745 167.134C180.348 191.737 220.238 191.737 244.841 167.134C269.444 142.531 269.444 102.641 244.841 78.0383C220.238 53.4352 180.348 53.4352 155.745 78.0383C131.142 102.641 131.142 142.531 155.745 167.134Z" stroke="url(#paint3_linear_6208_69986)"/>
|
||||
<path d="M147.26 175.619C176.549 204.908 224.037 204.908 253.326 175.619C282.615 146.33 282.615 98.8423 253.326 69.553C224.037 40.2636 176.549 40.2636 147.26 69.553C117.971 98.8423 117.971 146.33 147.26 175.619Z" stroke="url(#paint4_linear_6208_69986)"/>
|
||||
<path d="M200 123V46.053C200 46.053 216.757 44.923 234.595 53.0974C253.514 61.7673 259.948 70.5405 268.649 83.4428C278.072 97.4179 280 123 280 123H200Z" fill="url(#paint5_linear_6208_69986)"/>
|
||||
<path d="M303.5 18.5V225.5H96.5V18.5H303.5Z" stroke="#069E08"/>
|
||||
<path d="M303 24.25C305.347 24.25 307.25 22.3472 307.25 20C307.25 17.6528 305.347 15.75 303 15.75C300.653 15.75 298.75 17.6528 298.75 20C298.75 22.3472 300.653 24.25 303 24.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
|
||||
<path d="M303 230.25C305.347 230.25 307.25 228.347 307.25 226C307.25 223.653 305.347 221.75 303 221.75C300.653 221.75 298.75 223.653 298.75 226C298.75 228.347 300.653 230.25 303 230.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
|
||||
<path d="M97 23.25C99.3472 23.25 101.25 21.3472 101.25 19C101.25 16.6528 99.3472 14.75 97 14.75C94.6528 14.75 92.75 16.6528 92.75 19C92.75 21.3472 94.6528 23.25 97 23.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
|
||||
<path d="M97 229.25C99.3472 229.25 101.25 227.347 101.25 225C101.25 222.653 99.3472 220.75 97 220.75C94.6528 220.75 92.75 222.653 92.75 225C92.75 227.347 94.6528 229.25 97 229.25Z" fill="#003010" stroke="#069E08" stroke-width="1.5"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_6208_69986" x1="219.385" y1="141.678" x2="181.201" y2="103.494" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2E38FA"/>
|
||||
<stop offset="0.528154" stop-color="#48FF50"/>
|
||||
<stop offset="1" stop-color="#DAFFDC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_6208_69986" x1="227.87" y1="150.163" x2="172.716" y2="95.0088" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2E38FA"/>
|
||||
<stop offset="0.528154" stop-color="#48FF50"/>
|
||||
<stop offset="1" stop-color="#DAFFDC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_6208_69986" x1="236.355" y1="158.648" x2="164.231" y2="86.5236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2E38FA"/>
|
||||
<stop offset="0.528154" stop-color="#48FF50"/>
|
||||
<stop offset="1" stop-color="#DAFFDC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_6208_69986" x1="244.841" y1="167.134" x2="155.745" y2="78.0383" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2E38FA"/>
|
||||
<stop offset="0.528154" stop-color="#48FF50"/>
|
||||
<stop offset="1" stop-color="#DAFFDC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_6208_69986" x1="253.326" y1="175.619" x2="147.26" y2="69.553" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2E38FA"/>
|
||||
<stop offset="0.528154" stop-color="#48FF50"/>
|
||||
<stop offset="1" stop-color="#DAFFDC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_6208_69986" x1="240.289" y1="121.937" x2="221.82" y2="98.8034" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#003010" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#003010"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="380" height="183" viewBox="0 0 380 183" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.75" filter="url(#filter0_f_5953_11924)">
|
||||
<path d="M360 -60C360 7.93102 304.931 63 237 63C169.069 63 114 7.93102 114 -60C114 -127.931 169.069 -183 237 -183C304.931 -183 360 -127.931 360 -60Z" fill="url(#paint0_linear_5953_11924)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_5953_11924" x="-6" y="-303" width="486" height="486" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_5953_11924"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5953_11924" x1="360" y1="-60" x2="114" y2="-60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#48FF50"/>
|
||||
<stop offset="0.471846" stop-color="#108642"/>
|
||||
<stop offset="1" stop-color="#2E38FA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 990 B |
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('jetpack-connection', 'jetpack-script-data', 'react', 'react-dom', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n', 'wp-keycodes', 'wp-polyfill', 'wp-primitives', 'wp-private-apis', 'wp-url', 'wp-warning'), 'version' => '8dd753bb5b65f7782f75');
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @license React
|
||||
* use-sync-external-store-shim.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* The Activity Log React initial state.
|
||||
*
|
||||
* @package automattic/jetpack-activity-log
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Activity_Log;
|
||||
|
||||
use Automattic\Jetpack\Status;
|
||||
use Jetpack_Options;
|
||||
use function admin_url;
|
||||
use function esc_url_raw;
|
||||
use function get_bloginfo;
|
||||
use function get_locale;
|
||||
use function get_option;
|
||||
use function get_site_url;
|
||||
use function plugins_url;
|
||||
use function rest_url;
|
||||
use function wp_create_nonce;
|
||||
use function wp_json_encode;
|
||||
use function wp_parse_url;
|
||||
|
||||
/**
|
||||
* The Activity Log React initial state.
|
||||
*/
|
||||
class Initial_State {
|
||||
/**
|
||||
* Get the initial state data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_data() {
|
||||
$gmt_offset = get_option( 'gmt_offset' );
|
||||
$timezone_string = get_option( 'timezone_string' );
|
||||
$home_host = wp_parse_url( get_site_url(), PHP_URL_HOST );
|
||||
|
||||
return array(
|
||||
'API' => array(
|
||||
'WP_API_root' => esc_url_raw( rest_url() ),
|
||||
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
),
|
||||
'jetpackStatus' => array(
|
||||
'calypsoSlug' => ( new Status() )->get_site_suffix(),
|
||||
),
|
||||
'siteData' => array(
|
||||
'id' => Jetpack_Options::get_option( 'id' ),
|
||||
'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(),
|
||||
'adminUrl' => esc_url_raw( admin_url() ),
|
||||
'slug' => is_string( $home_host ) ? $home_host : '',
|
||||
'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0,
|
||||
'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '',
|
||||
'locale' => str_replace( '_', '-', (string) get_locale() ),
|
||||
// The paid-plan capability check. Drives the free-tier
|
||||
// upsell callout and matches the server-side clamp in
|
||||
// REST_Controller::get_activity_log(). The result is
|
||||
// cached in a site transient by the REST controller
|
||||
// (5-minute TTL); on a cache miss this call issues a
|
||||
// synchronous WPCOM request (~200–800ms typical, up to
|
||||
// the 2s internal timeout) that blocks page rendering
|
||||
// until it returns.
|
||||
'hasActivityLogsAccess' => REST_Controller::has_activity_logs_access(),
|
||||
),
|
||||
'nonces' => array(
|
||||
// Consumed by `UpsellCallout` to build a `redirect_to`
|
||||
// URL that invalidates the access cache on return from
|
||||
// checkout. See `Jetpack_Activity_Log::admin_init()`.
|
||||
'refreshAccess' => wp_create_nonce( Jetpack_Activity_Log::REFRESH_ACCESS_NONCE_ACTION ),
|
||||
),
|
||||
'assets' => array(
|
||||
'buildUrl' => plugins_url( '../build/', __FILE__ ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the initial state into a JavaScript variable.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function render() {
|
||||
return 'var JPACTIVITYLOG_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
/**
|
||||
* Primary class for the Jetpack Activity Log package.
|
||||
*
|
||||
* @package automattic/jetpack-activity-log
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Activity_Log;
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
use Automattic\Jetpack\Activity_Log\Initial_State as Activity_Log_Initial_State;
|
||||
use Automattic\Jetpack\Admin_UI\Admin_Menu;
|
||||
use Automattic\Jetpack\Assets;
|
||||
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use function add_action;
|
||||
use function add_filter;
|
||||
use function current_user_can;
|
||||
use function did_action;
|
||||
use function do_action;
|
||||
use function is_multisite;
|
||||
use function sanitize_text_field;
|
||||
use function wp_add_inline_script;
|
||||
use function wp_unslash;
|
||||
use function wp_verify_nonce;
|
||||
|
||||
/**
|
||||
* Class Jetpack_Activity_Log
|
||||
*
|
||||
* Registers the Activity Log admin page and its REST routes inside the
|
||||
* main Jetpack plugin.
|
||||
*/
|
||||
class Jetpack_Activity_Log {
|
||||
|
||||
/**
|
||||
* Admin page slug.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PAGE_SLUG = 'jetpack-activity-log';
|
||||
|
||||
/**
|
||||
* Script handle for the admin bundle.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const SCRIPT_HANDLE = 'jetpack-activity-log';
|
||||
|
||||
/**
|
||||
* Nonce action for refreshing the access flag after a checkout
|
||||
* return. Used by `admin_init()` below and exposed to the client via
|
||||
* Initial_State so the upsell CTA can embed a valid nonce in its
|
||||
* `redirect_to`. Same shape as `Social_Admin_Page::REFRESH_PLAN_NONCE_ACTION`.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const REFRESH_ACCESS_NONCE_ACTION = 'jetpack_activity_log_refresh_access';
|
||||
|
||||
/**
|
||||
* Entry point. Idempotent: safe to call from multiple bootstraps.
|
||||
*/
|
||||
public static function initialize() {
|
||||
if ( did_action( 'jetpack_activity_log_initialized' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
|
||||
add_filter( 'jetpack_package_versions', array( Package_Version::class, 'send_package_version_to_tracker' ) );
|
||||
|
||||
/**
|
||||
* Fires once the Jetpack Activity Log package has wired its hooks.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
do_action( 'jetpack_activity_log_initialized' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Activity Log submenu under Jetpack.
|
||||
*
|
||||
* Mirrors the gating used by the legacy my-jetpack "Activity Log" menu
|
||||
* item (connected user + non-multisite).
|
||||
*
|
||||
* @return string|null The resulting page's hook suffix, if registered.
|
||||
*/
|
||||
public static function add_wp_admin_submenu() {
|
||||
if ( ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$page_suffix = Admin_Menu::add_menu(
|
||||
/** "Activity Log" is a product name, do not translate. */
|
||||
'Activity Log',
|
||||
'Activity Log',
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( __CLASS__, 'render_page' ),
|
||||
14
|
||||
);
|
||||
|
||||
if ( $page_suffix ) {
|
||||
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
|
||||
}
|
||||
|
||||
return $page_suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Activity Log page should be shown to the current user.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available() {
|
||||
if ( is_multisite() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ( new Connection_Manager() )->is_user_connected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the admin page is loaded.
|
||||
*
|
||||
* When the user is returning from a successful checkout, the upsell
|
||||
* CTA appends `?refresh_access=1&_wpnonce=…` to the `redirect_to`
|
||||
* value it hands off to WordPress.com. Detect that here, verify the
|
||||
* nonce, and drop the cached paid-plan signal so
|
||||
* `Initial_State::get_data()` (which runs later in the same request,
|
||||
* when the bundle is enqueued) rehydrates from WPCOM instead of
|
||||
* re-serving the pre-checkout value. Mirrors the pattern in
|
||||
* `Automattic\Jetpack\Publicize\Social_Admin_Page::admin_init()`.
|
||||
*/
|
||||
public static function admin_init() {
|
||||
if ( isset( $_GET['refresh_access'] ) && isset( $_GET['_wpnonce'] ) ) {
|
||||
$nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) );
|
||||
if ( wp_verify_nonce( $nonce, self::REFRESH_ACCESS_NONCE_ACTION ) ) {
|
||||
REST_Controller::clear_access_cache();
|
||||
}
|
||||
}
|
||||
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the admin bundle and seed initial state.
|
||||
*/
|
||||
public static function enqueue_admin_scripts() {
|
||||
Assets::register_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
'../build/index.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'in_footer' => true,
|
||||
'textdomain' => 'jetpack-activity-log',
|
||||
)
|
||||
);
|
||||
Assets::enqueue_script( self::SCRIPT_HANDLE );
|
||||
|
||||
wp_add_inline_script( self::SCRIPT_HANDLE, ( new Activity_Log_Initial_State() )->render(), 'before' );
|
||||
Connection_Initial_State::render_script( self::SCRIPT_HANDLE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin page root node. React mounts into this element.
|
||||
*/
|
||||
public static function render_page() {
|
||||
?>
|
||||
<div id="jetpack-activity-log-root"></div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the REST routes backing the Activity Log UI.
|
||||
*
|
||||
* Routes are added in Phase 2. This method exists now so that the
|
||||
* `jetpack/v4/activity-log` namespace is reserved and the hook is wired.
|
||||
*/
|
||||
public static function register_rest_routes() {
|
||||
REST_Controller::register_rest_routes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* The Package_Version class.
|
||||
*
|
||||
* @package automattic/jetpack-activity-log
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Activity_Log;
|
||||
|
||||
/**
|
||||
* The Package_Version class.
|
||||
*
|
||||
* Does *not* use namespaced versioning ("VXXXX") because send_package_version_to_tracker() is used as a
|
||||
* "jetpack_package_versions" filter, and said filter gets run during a plugin upgrade, so it always expects to
|
||||
* find the "Package_Version" class with the same namespace, name, and interface.
|
||||
*/
|
||||
class Package_Version {
|
||||
|
||||
const PACKAGE_VERSION = '0.1.9';
|
||||
|
||||
const PACKAGE_SLUG = 'activity-log';
|
||||
|
||||
/**
|
||||
* Adds the package slug and version to the package version tracker's data.
|
||||
*
|
||||
* @param array $package_versions The package version array.
|
||||
*
|
||||
* @return array The package version array.
|
||||
*/
|
||||
public static function send_package_version_to_tracker( $package_versions ) {
|
||||
$package_versions[ self::PACKAGE_SLUG ] = self::PACKAGE_VERSION;
|
||||
|
||||
return $package_versions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
/**
|
||||
* The Activity Log REST Controller.
|
||||
*
|
||||
* Registers the `/jetpack/v4/activity-log/*` routes backing the admin
|
||||
* UI. Each route is a thin proxy to the corresponding WPCOM v2
|
||||
* endpoint, authenticated with the site's blog token.
|
||||
*
|
||||
* @package automattic/jetpack-activity-log
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Activity_Log;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Status\Visitor;
|
||||
use Jetpack_Options;
|
||||
use WP_Error;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Server;
|
||||
use function current_user_can;
|
||||
use function delete_site_transient;
|
||||
use function esc_html__;
|
||||
use function get_site_transient;
|
||||
use function http_build_query;
|
||||
use function is_wp_error;
|
||||
use function json_decode;
|
||||
use function register_rest_route;
|
||||
use function rest_ensure_response;
|
||||
use function set_site_transient;
|
||||
use function wp_remote_retrieve_body;
|
||||
use function wp_remote_retrieve_response_code;
|
||||
|
||||
/**
|
||||
* REST routes for the Activity Log UI.
|
||||
*/
|
||||
class REST_Controller {
|
||||
|
||||
/**
|
||||
* REST namespace used by this package.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const REST_NAMESPACE = 'jetpack/v4';
|
||||
|
||||
/**
|
||||
* Max items returned per request on the free tier. Matches the "20 most
|
||||
* recent events" copy used by Calypso's upsell callout.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const FREE_TIER_ITEM_CAP = 20;
|
||||
|
||||
/**
|
||||
* Site-transient TTL for the has-access capability check.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const CAPABILITY_CACHE_TTL = 5 * MINUTE_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Transient key prefix for the per-blog access cache.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CAPABILITY_CACHE_KEY = 'jetpack_activity_log_has_access_';
|
||||
|
||||
/**
|
||||
* Query params accepted by the list endpoint. Shape matches Calypso's
|
||||
* ActivityLogParams so the ported UI can forward its filter state
|
||||
* verbatim.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function list_args() {
|
||||
return array(
|
||||
'number' => array(
|
||||
'description' => __( 'Number of items to return per page.', 'jetpack-activity-log' ),
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => 1000,
|
||||
),
|
||||
'page' => array(
|
||||
'description' => __( '1-indexed page number.', 'jetpack-activity-log' ),
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
),
|
||||
'sort_order' => array(
|
||||
'description' => __( 'Sort direction.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'enum' => array( 'asc', 'desc' ),
|
||||
),
|
||||
'after' => array(
|
||||
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'before' => array(
|
||||
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'group' => array(
|
||||
'description' => __( 'Only return events in these groups.', 'jetpack-activity-log' ),
|
||||
'type' => 'array',
|
||||
'items' => array( 'type' => 'string' ),
|
||||
),
|
||||
'not_group' => array(
|
||||
'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
|
||||
'type' => 'array',
|
||||
'items' => array( 'type' => 'string' ),
|
||||
),
|
||||
'text_search' => array(
|
||||
'description' => __( 'Full-text search string.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
),
|
||||
'actor' => array(
|
||||
'description' => __( 'Only return events performed by these actor IDs.', 'jetpack-activity-log' ),
|
||||
'type' => 'array',
|
||||
'items' => array( 'type' => 'string' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query params accepted by the actors endpoint. Same date window as the
|
||||
* counts endpoint (no pagination, no sort, no filters) — we just want
|
||||
* the distinct set for the "Performed by" dropdown.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function actors_args() {
|
||||
return array(
|
||||
'number' => array(
|
||||
'description' => __( 'Cap on the number of events considered when collecting actors.', 'jetpack-activity-log' ),
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => 1000,
|
||||
),
|
||||
'after' => array(
|
||||
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'before' => array(
|
||||
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query params accepted by the group-counts endpoint. A subset of the
|
||||
* list params — no pagination or sort, no text search.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function group_counts_args() {
|
||||
return array(
|
||||
'number' => array(
|
||||
'description' => __( 'Cap on the number of events considered when counting groups.', 'jetpack-activity-log' ),
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => 1000,
|
||||
),
|
||||
'after' => array(
|
||||
'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'before' => array(
|
||||
'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ),
|
||||
'type' => 'string',
|
||||
'format' => 'date-time',
|
||||
),
|
||||
'group' => array(
|
||||
'description' => __( 'Only count events in these groups.', 'jetpack-activity-log' ),
|
||||
'type' => 'array',
|
||||
'items' => array( 'type' => 'string' ),
|
||||
),
|
||||
'not_group' => array(
|
||||
'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ),
|
||||
'type' => 'array',
|
||||
'items' => array( 'type' => 'string' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Activity Log REST routes.
|
||||
*
|
||||
* Hooked on `rest_api_init` by {@see Jetpack_Activity_Log::initialize()}.
|
||||
*/
|
||||
public static function register_rest_routes() {
|
||||
register_rest_route(
|
||||
self::REST_NAMESPACE,
|
||||
'/activity-log',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( __CLASS__, 'get_activity_log' ),
|
||||
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
|
||||
'args' => self::list_args(),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::REST_NAMESPACE,
|
||||
'/activity-log/count/group',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( __CLASS__, 'get_activity_log_group_counts' ),
|
||||
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
|
||||
'args' => self::group_counts_args(),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
self::REST_NAMESPACE,
|
||||
'/activity-log/actors',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( __CLASS__, 'get_activity_log_actors' ),
|
||||
'permission_callback' => array( __CLASS__, 'permissions_callback' ),
|
||||
'args' => self::actors_args(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback. Mirrors the menu gating — any admin on a
|
||||
* non-multisite install with a user-level WPCOM connection can read
|
||||
* the log. A user-level connection is required because the upstream
|
||||
* WPCOM endpoint is user-gated (it needs to identify *which* admin
|
||||
* is asking); signing as the blog gets rejected with "Only
|
||||
* Administrators can query information about the current site."
|
||||
*
|
||||
* @return bool|WP_Error
|
||||
*/
|
||||
public static function permissions_callback() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! ( new Connection_Manager() )->is_user_connected() ) {
|
||||
return new WP_Error(
|
||||
'activity_log_user_not_connected',
|
||||
esc_html__( 'Your WordPress.com account is not connected to this site. Connect it to use the Activity Log.', 'jetpack-activity-log' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site's current plan unlocks the full activity log.
|
||||
*
|
||||
* Checks the WPCOM `/sites/{id}/features` endpoint for the
|
||||
* `full-activity-log` feature flag and caches the boolean for
|
||||
* {@see self::CAPABILITY_CACHE_TTL} seconds in a site transient so the
|
||||
* list endpoint doesn't pay the round-trip on every pagination page.
|
||||
* The cache is per-blog (fine for multisite) and keyed on `blog_id`.
|
||||
*
|
||||
* Checking the feature flag (rather than a specific plan slug or the
|
||||
* rewind state) means Jetpack Complete, Security, Personal, and all
|
||||
* standalone Backup plans are covered correctly, regardless of whether
|
||||
* backup credentials have been configured.
|
||||
*
|
||||
* @return bool True when the site has the full-activity-log feature.
|
||||
*/
|
||||
public static function has_activity_logs_access() {
|
||||
$blog_id = (int) Jetpack_Options::get_option( 'id' );
|
||||
if ( ! $blog_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cache_key = self::CAPABILITY_CACHE_KEY . $blog_id;
|
||||
$cached = get_site_transient( $cache_key );
|
||||
if ( false !== $cached ) {
|
||||
return 'yes' === $cached;
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_blog(
|
||||
sprintf( '/sites/%d/features', $blog_id ),
|
||||
'1.1',
|
||||
array( 'timeout' => 2 )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
// Fail closed: assume no access if we can't reach WPCOM. Cache for
|
||||
// a short window to avoid hammering the endpoint on every call.
|
||||
set_site_transient( $cache_key, 'no', 10 );
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ) );
|
||||
$active = is_object( $body ) && isset( $body->active ) && is_array( $body->active ) ? $body->active : array();
|
||||
$has_it = in_array( 'full-activity-log', $active, true );
|
||||
set_site_transient( $cache_key, $has_it ? 'yes' : 'no', self::CAPABILITY_CACHE_TTL );
|
||||
return $has_it;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached has-access flag. Exposed so front-end flows that
|
||||
* know the plan just changed (e.g. a successful checkout redirect) can
|
||||
* force a refresh on the next request.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear_access_cache() {
|
||||
$blog_id = (int) Jetpack_Options::get_option( 'id' );
|
||||
if ( $blog_id ) {
|
||||
delete_site_transient( self::CAPABILITY_CACHE_KEY . $blog_id );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-tier params that survive the filter strip in
|
||||
* {@see self::get_activity_log()}. Anything outside this list is
|
||||
* nulled out before the request reaches WPCOM.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
const FREE_TIER_ALLOWED_PARAMS = array( 'number', 'page', 'sort_order' );
|
||||
|
||||
/**
|
||||
* Proxy the paginated activity list.
|
||||
*
|
||||
* Enforces the free-tier boundary server-side. When the site doesn't
|
||||
* have access:
|
||||
*
|
||||
* 1. `number` is clamped to {@see self::FREE_TIER_ITEM_CAP}.
|
||||
* 2. `page` is forced to 1.
|
||||
* 3. All filter inputs (`after`, `before`, `group`, `not_group`,
|
||||
* `text_search`, `actor`) are dropped.
|
||||
*
|
||||
* Together these mean a client-side bypass (DevTools, direct
|
||||
* `wp.apiFetch`) is bounded to "the 20 most recent events overall" —
|
||||
* the same dataset the locked-down UI surfaces. Without (3),
|
||||
* date-walking via `before` would let a free-tier caller page through
|
||||
* the entire history 20 rows at a time.
|
||||
*
|
||||
* @param WP_REST_Request $request Request.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_activity_log( WP_REST_Request $request ) {
|
||||
if ( ! self::has_activity_logs_access() ) {
|
||||
// Mutating the request in-place is deliberate: any
|
||||
// downstream `rest_request_*` filter sees the clamped
|
||||
// values, not the caller's originals. For a security
|
||||
// clamp that's the right side of the trade — no filter
|
||||
// can undo the limit.
|
||||
$requested = (int) $request->get_param( 'number' );
|
||||
$request->set_param(
|
||||
'number',
|
||||
$requested > 0 ? min( $requested, self::FREE_TIER_ITEM_CAP ) : self::FREE_TIER_ITEM_CAP
|
||||
);
|
||||
$request->set_param( 'page', 1 );
|
||||
|
||||
foreach ( array_keys( self::list_args() ) as $key ) {
|
||||
if ( ! in_array( $key, self::FREE_TIER_ALLOWED_PARAMS, true ) ) {
|
||||
$request->set_param( $key, null );
|
||||
}
|
||||
}
|
||||
}
|
||||
return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy the group-counts endpoint.
|
||||
*
|
||||
* Deliberately not tier-clamped — the free-tier list clamp
|
||||
* (`number` → 20 / `page` → 1) is the security boundary; the group
|
||||
* counts are cosmetic metadata that powers the filter dropdown. A
|
||||
* stable, full-history count keeps the dropdown from flickering as
|
||||
* users type in the search field, matching Calypso's behavior at
|
||||
* `wp-calypso:client/dashboard/sites/logs-activity/dataviews/
|
||||
* index.tsx:100-102`.
|
||||
*
|
||||
* @param WP_REST_Request $request Request.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_activity_log_group_counts( WP_REST_Request $request ) {
|
||||
return self::proxy_get( '/activity/count/group', $request, array_keys( self::group_counts_args() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy the actors endpoint. Returns the distinct actors that have at
|
||||
* least one event in the requested date window — used to populate the
|
||||
* "Performed by" filter dropdown.
|
||||
*
|
||||
* Tier-clamping mirrors the group-counts endpoint: the list clamp at
|
||||
* {@see self::get_activity_log()} is the security boundary, so the
|
||||
* actors metadata is fine to serve unconditionally.
|
||||
*
|
||||
* @param WP_REST_Request $request Request.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function get_activity_log_actors( WP_REST_Request $request ) {
|
||||
return self::proxy_get( '/activity/actors', $request, array_keys( self::actors_args() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: forward whitelisted query params from $request to the
|
||||
* equivalent WPCOM v2 path under `/sites/{blog_id}`.
|
||||
*
|
||||
* @param string $wpcom_path Path relative to the site, starting with "/".
|
||||
* @param WP_REST_Request $request Incoming request.
|
||||
* @param array $allowed_keys Params to forward. Any unset keys are dropped.
|
||||
* @return mixed Decoded JSON response from WPCOM, or WP_Error on failure.
|
||||
*/
|
||||
private static function proxy_get( $wpcom_path, WP_REST_Request $request, array $allowed_keys ) {
|
||||
$blog_id = Jetpack_Options::get_option( 'id' );
|
||||
if ( ! $blog_id ) {
|
||||
return new WP_Error(
|
||||
'activity_log_not_connected',
|
||||
esc_html__( 'This site is not connected to WordPress.com.', 'jetpack-activity-log' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$params = array();
|
||||
foreach ( $allowed_keys as $key ) {
|
||||
$value = $request->get_param( $key );
|
||||
if ( $value !== null ) {
|
||||
$params[ $key ] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$path = sprintf( '/sites/%d%s', (int) $blog_id, $wpcom_path );
|
||||
if ( ! empty( $params ) ) {
|
||||
$path .= '?' . http_build_query( $params );
|
||||
}
|
||||
|
||||
// Sign as the current user, not the blog: the upstream /sites/{id}/activity
|
||||
// endpoint checks that a specific admin is asking. Forward the visitor IP
|
||||
// so WPCOM logs match the existing /jetpack/v4/site/activity proxy.
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
$path,
|
||||
'2',
|
||||
array(
|
||||
'method' => 'GET',
|
||||
'headers' => array(
|
||||
'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
|
||||
),
|
||||
),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error(
|
||||
'activity_log_request_failed',
|
||||
$response->get_error_message(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( 200 !== $status ) {
|
||||
return new WP_Error(
|
||||
'activity_log_request_failed',
|
||||
isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch activity log.', 'jetpack-activity-log' ),
|
||||
array( 'status' => $status ? $status : 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response( $body );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.6] - 2026-06-26
|
||||
### Changed
|
||||
- Update dependencies. [#48834]
|
||||
|
||||
## [0.9.5] - 2026-06-23
|
||||
### Changed
|
||||
- Update package dependencies. [#49831]
|
||||
|
||||
## [0.9.4] - 2026-06-22
|
||||
### Changed
|
||||
- Update package dependencies. [#49691]
|
||||
|
||||
## [0.9.3] - 2026-06-15
|
||||
### Changed
|
||||
- Update dependencies. [#48834]
|
||||
|
||||
## [0.9.2] - 2026-06-09
|
||||
### Changed
|
||||
- Update package dependencies. [#49273]
|
||||
|
||||
## [0.9.1] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.9.0] - 2026-06-05
|
||||
### Added
|
||||
- Add a shared, token-only WPDS design-tokens stylesheet and enqueue it on Jetpack admin pages so `var(--wpds-*)` values resolve at runtime. [#49345]
|
||||
|
||||
## [0.8.9] - 2026-06-03
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.8.8] - 2026-06-02
|
||||
### Changed
|
||||
- Update dependencies. [#48834]
|
||||
|
||||
## [0.8.7] - 2026-06-01
|
||||
### Added
|
||||
- Hide WordPress core admin notices on Jetpack admin pages. [#49237]
|
||||
|
||||
## [0.8.6] - 2026-05-21
|
||||
### Changed
|
||||
- Update package dependencies. [#48405]
|
||||
|
||||
## [0.8.5] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.8.4] - 2026-05-14
|
||||
### Changed
|
||||
- Update dependencies. [#48778]
|
||||
|
||||
## [0.8.3] - 2026-05-11
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [0.8.2] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [0.8.1] - 2026-04-20
|
||||
### Changed
|
||||
- Update package dependencies. [#48106]
|
||||
|
||||
## [0.8.0] - 2026-04-10
|
||||
### Changed
|
||||
- Hide the sidebar upsell when the site is not connected to Jetpack or is in offline mode. [#47967]
|
||||
|
||||
## [0.7.1] - 2026-04-09
|
||||
### Changed
|
||||
- Update package dependencies. [#47890]
|
||||
|
||||
## [0.7.0] - 2026-04-06
|
||||
### Added
|
||||
- Add "Upgrade Jetpack" menu item for free users in the Jetpack admin menu. [#47418]
|
||||
- Admin: Record Tracks when the Jetpack sidebar "Upgrade Jetpack" link is clicked. [#47937]
|
||||
|
||||
### Changed
|
||||
- Update design of the sidebar upsell. [#47909]
|
||||
|
||||
### Fixed
|
||||
- Fix upgrade menu item color on problematic admin color schemes and add build pipeline for CSS assets. [#47903]
|
||||
|
||||
## [0.6.0] - 2026-03-30
|
||||
### Changed
|
||||
- Sidebar: Change "Anti-spam" to "Akismet Anti-spam". [#47834]
|
||||
|
||||
## [0.5.12] - 2026-03-09
|
||||
### Changed
|
||||
- Simplify Akismet admin menu title from 'Akismet Anti-spam' to 'Anti-spam'. [#47417]
|
||||
|
||||
## [0.5.11] - 2025-08-04
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.10] - 2025-06-06
|
||||
### Fixed
|
||||
- Fix the Jetpack submenu order for WordPress.com sites that use Nav Unification. [#43781]
|
||||
|
||||
## [0.5.9] - 2025-05-05
|
||||
### Fixed
|
||||
- Remove the Jetpack submenu only if My Jetpack is present. [#43282]
|
||||
|
||||
## [0.5.8] - 2025-04-28
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.7] - 2025-03-21
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.6] - 2025-03-17
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.5] - 2025-03-12
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.4] - 2025-03-05
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.3] - 2025-02-24
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [0.5.2] - 2025-02-03
|
||||
### Added
|
||||
- Add `remove_menu` method to `Admin_Menu` class. [#41422]
|
||||
|
||||
## [0.5.1] - 2024-11-25
|
||||
### Changed
|
||||
- Update dependencies. [#40286]
|
||||
|
||||
## [0.5.0] - 2024-11-14
|
||||
### Removed
|
||||
- General: Update minimum PHP version to 7.2. [#40147]
|
||||
|
||||
## [0.4.6] - 2024-11-04
|
||||
### Added
|
||||
- Enable test coverage. [#39961]
|
||||
|
||||
## [0.4.5] - 2024-09-05
|
||||
### Changed
|
||||
- Jetpack menu: only register Jetpack admin page for contributor roles and above. [#39081]
|
||||
|
||||
## [0.4.4] - 2024-08-29
|
||||
### Changed
|
||||
- Admin menu: change order of Jetpack sub-menu items [#39095]
|
||||
|
||||
## [0.4.3] - 2024-08-23
|
||||
### Changed
|
||||
- Updated package dependencies. [#39004]
|
||||
|
||||
## [0.4.2] - 2024-04-22
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.4.1] - 2024-03-12
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.4.0] - 2024-03-01
|
||||
### Added
|
||||
- Register menus in network admin as well as regular admin. [#36058]
|
||||
|
||||
## [0.3.2] - 2024-01-29
|
||||
### Fixed
|
||||
- Wait until 'admin_menu' action to call `add_menu()`, to avoid triggering the l10n load too early. [#35279]
|
||||
|
||||
## [0.3.1] - 2023-11-24
|
||||
|
||||
## [0.3.0] - 2023-11-20
|
||||
### Changed
|
||||
- Updated required PHP version to >= 7.0. [#34192]
|
||||
|
||||
## [0.2.25] - 2023-11-14
|
||||
|
||||
## [0.2.24] - 2023-10-30
|
||||
### Fixed
|
||||
- Handle Akismet submenu even if Jetpack is present, as Jetpack now relies on this package to do so. [#33559]
|
||||
|
||||
## [0.2.23] - 2023-09-19
|
||||
### Changed
|
||||
- Updated Jetpack submenu sort order so individual features are alpha-sorted. [#32958]
|
||||
|
||||
## [0.2.22] - 2023-09-11
|
||||
### Fixed
|
||||
- Akismet: update naming to common form [#32908]
|
||||
|
||||
## [0.2.21] - 2023-08-23
|
||||
### Changed
|
||||
- Updated package dependencies. [#32605]
|
||||
|
||||
## [0.2.20] - 2023-04-25
|
||||
### Fixed
|
||||
- Avoid errors when used in combination with an older version of the Logo package. [#30136]
|
||||
|
||||
## [0.2.19] - 2023-04-10
|
||||
### Added
|
||||
- Add Jetpack Autoloader package suggestion. [#29988]
|
||||
|
||||
## [0.2.18] - 2023-04-04
|
||||
### Changed
|
||||
- Menu icon: update to latest version of the Jetpack logo [#29418]
|
||||
|
||||
## [0.2.17] - 2023-02-20
|
||||
### Changed
|
||||
- Minor internal updates.
|
||||
|
||||
## [0.2.16] - 2023-01-25
|
||||
### Changed
|
||||
- Minor internal updates.
|
||||
|
||||
## [0.2.15] - 2023-01-11
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.14] - 2022-12-02
|
||||
### Changed
|
||||
- Updated package dependencies. [#27688]
|
||||
|
||||
## [0.2.13] - 2022-11-22
|
||||
### Changed
|
||||
- Updated package dependencies. [#27043]
|
||||
|
||||
## [0.2.12] - 2022-09-20
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.11] - 2022-07-26
|
||||
### Changed
|
||||
- Updated package dependencies. [#25158]
|
||||
|
||||
## [0.2.10] - 2022-07-12
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.9] - 2022-06-21
|
||||
### Changed
|
||||
- Renaming master to trunk.
|
||||
|
||||
## [0.2.8] - 2022-06-14
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.7] - 2022-04-26
|
||||
### Changed
|
||||
- Update package.json metadata.
|
||||
|
||||
## [0.2.6] - 2022-04-05
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.5] - 2022-03-08
|
||||
### Fixed
|
||||
- Do not handle Akismet submenu if Jetpack plugin is present
|
||||
|
||||
## [0.2.4] - 2022-02-09
|
||||
### Added
|
||||
- Support for akismet menu with stand-alone plugins
|
||||
|
||||
### Fixed
|
||||
- Fixes menu order working around a bug in add_submenu_page
|
||||
|
||||
## [0.2.3] - 2022-01-25
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
## [0.2.2] - 2022-01-18
|
||||
### Changed
|
||||
- General: update required node version to v16.13.2
|
||||
|
||||
## [0.2.1] - 2022-01-04
|
||||
### Changed
|
||||
- Switch to pcov for code coverage.
|
||||
- Updated package dependencies
|
||||
|
||||
## [0.2.0] - 2021-12-14
|
||||
### Added
|
||||
- New method to get the top level menu item
|
||||
|
||||
## [0.1.1] - 2021-11-17
|
||||
### Changed
|
||||
- Set `convertDeprecationsToExceptions` true in PHPUnit config.
|
||||
- Update PHPUnit configs to include just what needs coverage rather than include everything then try to exclude stuff that doesn't.
|
||||
|
||||
## 0.1.0 - 2021-10-13
|
||||
### Added
|
||||
- Created the package.
|
||||
|
||||
### Changed
|
||||
- Updated package dependencies.
|
||||
|
||||
### Fixed
|
||||
- Fixing menu visibility issues.
|
||||
|
||||
[0.9.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.5...0.9.6
|
||||
[0.9.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.4...0.9.5
|
||||
[0.9.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.3...0.9.4
|
||||
[0.9.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.2...0.9.3
|
||||
[0.9.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.1...0.9.2
|
||||
[0.9.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.9.0...0.9.1
|
||||
[0.9.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.9...0.9.0
|
||||
[0.8.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.8...0.8.9
|
||||
[0.8.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.7...0.8.8
|
||||
[0.8.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.6...0.8.7
|
||||
[0.8.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.5...0.8.6
|
||||
[0.8.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.4...0.8.5
|
||||
[0.8.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.3...0.8.4
|
||||
[0.8.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.2...0.8.3
|
||||
[0.8.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.1...0.8.2
|
||||
[0.8.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.8.0...0.8.1
|
||||
[0.8.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.7.1...0.8.0
|
||||
[0.7.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.7.0...0.7.1
|
||||
[0.7.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.6.0...0.7.0
|
||||
[0.6.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.12...0.6.0
|
||||
[0.5.12]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.11...0.5.12
|
||||
[0.5.11]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.10...0.5.11
|
||||
[0.5.10]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.9...0.5.10
|
||||
[0.5.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.8...0.5.9
|
||||
[0.5.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.7...0.5.8
|
||||
[0.5.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.6...0.5.7
|
||||
[0.5.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.5...0.5.6
|
||||
[0.5.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.4...0.5.5
|
||||
[0.5.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.3...0.5.4
|
||||
[0.5.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.2...0.5.3
|
||||
[0.5.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.1...0.5.2
|
||||
[0.5.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.5.0...0.5.1
|
||||
[0.5.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.6...0.5.0
|
||||
[0.4.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.5...0.4.6
|
||||
[0.4.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.4...0.4.5
|
||||
[0.4.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.3...0.4.4
|
||||
[0.4.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.2...0.4.3
|
||||
[0.4.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.1...0.4.2
|
||||
[0.4.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.4.0...0.4.1
|
||||
[0.4.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.2...0.4.0
|
||||
[0.3.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.1...0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.3.0...0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.25...0.3.0
|
||||
[0.2.25]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.24...0.2.25
|
||||
[0.2.24]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.23...0.2.24
|
||||
[0.2.23]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.22...0.2.23
|
||||
[0.2.22]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.21...0.2.22
|
||||
[0.2.21]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.20...0.2.21
|
||||
[0.2.20]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.19...0.2.20
|
||||
[0.2.19]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.18...0.2.19
|
||||
[0.2.18]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.17...0.2.18
|
||||
[0.2.17]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.16...0.2.17
|
||||
[0.2.16]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.15...0.2.16
|
||||
[0.2.15]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.14...0.2.15
|
||||
[0.2.14]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.13...0.2.14
|
||||
[0.2.13]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.12...0.2.13
|
||||
[0.2.12]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.11...0.2.12
|
||||
[0.2.11]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.10...0.2.11
|
||||
[0.2.10]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.9...0.2.10
|
||||
[0.2.9]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.8...0.2.9
|
||||
[0.2.8]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.7...0.2.8
|
||||
[0.2.7]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.6...0.2.7
|
||||
[0.2.6]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.5...0.2.6
|
||||
[0.2.5]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.4...0.2.5
|
||||
[0.2.4]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.3...0.2.4
|
||||
[0.2.3]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.2...0.2.3
|
||||
[0.2.2]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.1...0.2.2
|
||||
[0.2.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.2.0...0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/jetpack-admin-ui/compare/0.1.1...0.2.0
|
||||
[0.1.1]: https://github.com/Automattic/jetpack-admin-ui/compare/0.1.0...0.1.1
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array(), 'version' => '9928b439705112de9a6a');
|
||||
@@ -0,0 +1 @@
|
||||
document.addEventListener("DOMContentLoaded",()=>{const e=window.jetpackAdminUiUpgradeMenu;if(void 0===e)return;const t=e.menuItemClass;if(!t)return;const a=document.querySelector(`li.${t} a`);a&&(void 0!==window?.analytics&&e.tracksUserData&&window.analytics.initialize(e.tracksUserData?.userid,e.tracksUserData?.username),a.addEventListener("click",function(){window.analytics?.tracks?.recordEvent("jetpack_sidebar_free_upgrade_click",e.tracksEventData)}))});
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array(), 'version' => '3c06881fce01e490b54a');
|
||||
@@ -0,0 +1 @@
|
||||
:root{--font-title-large:36px;--font-title-small:24px;--font-body:16px;--font-label:12px;--jp-black:#000;--jp-black-80:#2c3338;--jp-white:#fff;--jp-white-off:#f9f9f6;--jp-gray:#dcdcde;--jp-gray-0:#f6f7f7;--jp-gray-5:#dcdcde;--jp-gray-10:#c3c4c7;--jp-gray-20:#a7aaad;--jp-gray-30:#8c8f94;--jp-gray-40:#787c82;--jp-gray-50:#646970;--jp-gray-60:#50575e;--jp-gray-70:#3c434a;--jp-gray-80:#2c3338;--jp-gray-90:#1d2327;--jp-gray-100:#101517;--jp-gray-off:#e2e2df;--jp-yellow-5:#f5e6b3;--jp-yellow-10:#f2cf75;--jp-yellow-40:#c08c00;--jp-orange-20:#faa754;--jp-blue-5:#ced9f2;--jp-red-0:#f7ebec;--jp-red-50:#d63638;--jp-red-60:#b32d2e;--jp-red-80:#8a2424;--jp-red:#d63639;--jp-pink:#c9356e;--jp-green-0:#f0f2eb;--jp-green-5:#d0e6b8;--jp-green-10:#9dd977;--jp-green-20:#64ca43;--jp-green-30:#2fb41f;--jp-green-40:#069e08;--jp-green-50:#008710;--jp-green-60:#007117;--jp-green-70:#005b18;--jp-green-80:#004515;--jp-green-90:#003010;--jp-green-100:#001c09;--jp-green:#069e08;--jp-green-mint:#d3f6d5;--jp-green-primary:var(--jp-green-40);--jp-green-secondary:var(--jp-green-30);--jp-border-radius:4px;--jp-border-radius-rna:8px;--jp-menu-border-height:1px;--jp-underline-thickness:2px;--jp-modal-padding-large:32px;--jp-modal-padding:24px;--jp-modal-padding-small:16px;--jp-modal-radius:8px;--jp-button-padding:8px;--jp-button-radius:4px;--jp-gap:16px}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a,#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-40)!important;border-radius:var(--jp-border-radius)!important;box-shadow:none!important;color:var(--jp-white)!important;margin:2px 4px;text-align:center;text-wrap:balance}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-30)!important}
|
||||
@@ -0,0 +1 @@
|
||||
:root{--font-title-large:36px;--font-title-small:24px;--font-body:16px;--font-label:12px;--jp-black:#000;--jp-black-80:#2c3338;--jp-white:#fff;--jp-white-off:#f9f9f6;--jp-gray:#dcdcde;--jp-gray-0:#f6f7f7;--jp-gray-5:#dcdcde;--jp-gray-10:#c3c4c7;--jp-gray-20:#a7aaad;--jp-gray-30:#8c8f94;--jp-gray-40:#787c82;--jp-gray-50:#646970;--jp-gray-60:#50575e;--jp-gray-70:#3c434a;--jp-gray-80:#2c3338;--jp-gray-90:#1d2327;--jp-gray-100:#101517;--jp-gray-off:#e2e2df;--jp-yellow-5:#f5e6b3;--jp-yellow-10:#f2cf75;--jp-yellow-40:#c08c00;--jp-orange-20:#faa754;--jp-blue-5:#ced9f2;--jp-red-0:#f7ebec;--jp-red-50:#d63638;--jp-red-60:#b32d2e;--jp-red-80:#8a2424;--jp-red:#d63639;--jp-pink:#c9356e;--jp-green-0:#f0f2eb;--jp-green-5:#d0e6b8;--jp-green-10:#9dd977;--jp-green-20:#64ca43;--jp-green-30:#2fb41f;--jp-green-40:#069e08;--jp-green-50:#008710;--jp-green-60:#007117;--jp-green-70:#005b18;--jp-green-80:#004515;--jp-green-90:#003010;--jp-green-100:#001c09;--jp-green:#069e08;--jp-green-mint:#d3f6d5;--jp-green-primary:var(--jp-green-40);--jp-green-secondary:var(--jp-green-30);--jp-border-radius:4px;--jp-border-radius-rna:8px;--jp-menu-border-height:1px;--jp-underline-thickness:2px;--jp-modal-padding-large:32px;--jp-modal-padding:24px;--jp-modal-padding-small:16px;--jp-modal-radius:8px;--jp-button-padding:8px;--jp-button-radius:4px;--jp-gap:16px}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a,#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-40)!important;border-radius:var(--jp-border-radius)!important;box-shadow:none!important;color:var(--jp-white)!important;margin:2px 4px;text-align:center;text-wrap:balance}#adminmenu li.jetpack-wpadmin-sidebar-free-plan-upsell-menu-item>a:hover{background:var(--jp-green-30)!important}
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array(), 'version' => '9909656c2d47702db3c4');
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Marks the wp-admin Jetpack sidebar "Upgrade Jetpack" item for Tracks.
|
||||
*/
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
const config = window.jetpackAdminUiUpgradeMenu;
|
||||
if ( typeof config === 'undefined' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const className = config.menuItemClass;
|
||||
if ( ! className ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Class comes from PHP (UPGRADE_MENU_SLUG); safe for querySelector.
|
||||
const item = document.querySelector( `li.${ className } a` );
|
||||
if ( ! item ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize Tracks
|
||||
if ( 'undefined' !== typeof window?.analytics && config.tracksUserData ) {
|
||||
window.analytics.initialize( config.tracksUserData?.userid, config.tracksUserData?.username );
|
||||
}
|
||||
|
||||
item.addEventListener( 'click', function () {
|
||||
window.analytics?.tracks?.recordEvent(
|
||||
'jetpack_sidebar_free_upgrade_click',
|
||||
config.tracksEventData
|
||||
);
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,601 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Menu Registration
|
||||
*
|
||||
* @package automattic/jetpack-admin-ui
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Admin_UI;
|
||||
|
||||
use Automattic\Jetpack\Tracking;
|
||||
use Jetpack_Options;
|
||||
use Jetpack_Tracks_Client;
|
||||
|
||||
/**
|
||||
* This class offers a wrapper to add_submenu_page and makes sure stand-alone plugin's menu items are always added under the Jetpack top level menu.
|
||||
* If the Jetpack top level was not previously registered by other plugin, it will be registered here.
|
||||
*/
|
||||
class Admin_Menu {
|
||||
|
||||
const PACKAGE_VERSION = '0.9.6';
|
||||
|
||||
/**
|
||||
* Slug used for the upgrade menu item and redirect URL.
|
||||
*
|
||||
* Keep the slug in sync with `$upgrade-menu-slug` at admin-ui-upgrade-menu.scss
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const UPGRADE_MENU_SLUG = 'jetpack-wpadmin-sidebar-free-plan-upsell-menu-item';
|
||||
|
||||
/**
|
||||
* Fallback upgrade URL when the Redirect class is unavailable.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
|
||||
|
||||
/**
|
||||
* Handle for the shared, token-only WPDS design-tokens stylesheet.
|
||||
*
|
||||
* Registered once and enqueued on every Jetpack admin page so that
|
||||
* `var(--wpds-*)` values resolve at runtime instead of falling back to
|
||||
* their hand-written hex defaults.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const DESIGN_TOKENS_HANDLE = 'jetpack-admin-ui-design-tokens';
|
||||
|
||||
/**
|
||||
* Whether this class has been initialized
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* List of menu items enqueued to be added
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $menu_items = array();
|
||||
|
||||
/**
|
||||
* Hook suffixes of the pages registered through this class.
|
||||
*
|
||||
* Used to scope the design-tokens stylesheet to Jetpack admin pages.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $page_hooks = array();
|
||||
|
||||
/**
|
||||
* Optional connection manager dependency.
|
||||
*
|
||||
* @var object|null
|
||||
*/
|
||||
private static $connection_manager = null;
|
||||
|
||||
/**
|
||||
* Initialize the class and set up the main hook
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init() {
|
||||
if ( ! self::$initialized ) {
|
||||
self::$initialized = true;
|
||||
self::handle_akismet_menu();
|
||||
add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
|
||||
add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the Akismet menu item when used alongside other stand-alone plugins
|
||||
*
|
||||
* When Jetpack plugin is present, Akismet menu item is moved under the Jetpack top level menu, but if Akismet is active alongside other stand-alone plugins,
|
||||
* we use this method to move the menu item.
|
||||
*/
|
||||
private static function handle_akismet_menu() {
|
||||
if ( class_exists( 'Akismet_Admin' ) ) {
|
||||
add_action(
|
||||
'admin_menu',
|
||||
function () {
|
||||
// Prevent Akismet from adding a menu item.
|
||||
remove_action( 'admin_menu', array( 'Akismet_Admin', 'admin_menu' ), 5 );
|
||||
|
||||
// Add an Anti-spam menu item for Jetpack.
|
||||
self::add_menu( __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), __( 'Akismet Anti-spam', 'jetpack-admin-ui' ), 'manage_options', 'akismet-key-config', array( 'Akismet_Admin', 'display_page' ), 6 );
|
||||
},
|
||||
4
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to the admin_menu and network_admin_menu hooks that will register the enqueued menu items
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function admin_menu_hook_callback() {
|
||||
$can_see_toplevel_menu = true;
|
||||
$jetpack_plugin_present = class_exists( 'Jetpack_React_Page' );
|
||||
$icon = method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' )
|
||||
? ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo()
|
||||
: 'dashicons-admin-plugins';
|
||||
|
||||
if ( ! $jetpack_plugin_present ) {
|
||||
add_menu_page(
|
||||
'Jetpack',
|
||||
'Jetpack',
|
||||
'edit_posts',
|
||||
'jetpack',
|
||||
'__return_null',
|
||||
$icon,
|
||||
3
|
||||
);
|
||||
|
||||
// If Jetpack plugin is not present, user will only be able to see this menu if they have enough capability to at least one of the sub menus being added.
|
||||
$can_see_toplevel_menu = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The add_sub_menu function has a bug and will not keep the right order of menu items.
|
||||
*
|
||||
* @see https://core.trac.wordpress.org/ticket/52035
|
||||
* Let's order the items before registering them.
|
||||
* Since this all happens after the Jetpack plugin menu items were added, all items will be added after Jetpack plugin items - unless position is very low number (smaller than the number of menu items present in Jetpack plugin).
|
||||
*/
|
||||
usort(
|
||||
self::$menu_items,
|
||||
function ( $a, $b ) {
|
||||
$position_a = empty( $a['position'] ) ? 0 : $a['position'];
|
||||
$position_b = empty( $b['position'] ) ? 0 : $b['position'];
|
||||
$result = $position_a <=> $position_b;
|
||||
|
||||
if ( 0 === $result ) {
|
||||
$result = strcmp( $a['menu_title'], $b['menu_title'] );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
);
|
||||
|
||||
foreach ( self::$menu_items as $menu_item ) {
|
||||
if ( ! current_user_can( $menu_item['capability'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$can_see_toplevel_menu = true;
|
||||
|
||||
add_submenu_page(
|
||||
'jetpack',
|
||||
$menu_item['page_title'],
|
||||
$menu_item['menu_title'],
|
||||
$menu_item['capability'],
|
||||
$menu_item['menu_slug'],
|
||||
$menu_item['function'],
|
||||
$menu_item['position']
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! $jetpack_plugin_present ) {
|
||||
remove_submenu_page( 'jetpack', 'jetpack' );
|
||||
}
|
||||
|
||||
if ( ! $can_see_toplevel_menu ) {
|
||||
remove_menu_page( 'jetpack' );
|
||||
}
|
||||
|
||||
self::maybe_add_upgrade_menu_item();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new submenu to the Jetpack Top level menu
|
||||
*
|
||||
* The parameters this method accepts are the same as @see add_submenu_page. This class will
|
||||
* aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
|
||||
* Jetpack top level menu. It will also handle the top level menu registration in case the Jetpack plugin is not present.
|
||||
*
|
||||
* @param string $page_title The text to be displayed in the title tags of the page when the menu
|
||||
* is selected.
|
||||
* @param string $menu_title The text to be used for the menu.
|
||||
* @param string $capability The capability required for this menu to be displayed to the user.
|
||||
* @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
|
||||
* and only include lowercase alphanumeric, dashes, and underscores characters
|
||||
* to be compatible with sanitize_key().
|
||||
* @param callable|null $function The function to be called to output the content for this page.
|
||||
* @param int $position The position in the menu order this item should appear. Leave empty typically.
|
||||
*
|
||||
* @return string The resulting page's hook_suffix
|
||||
*/
|
||||
public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null ) {
|
||||
self::init();
|
||||
self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position' );
|
||||
|
||||
/**
|
||||
* Let's return the page hook so consumers can use.
|
||||
* We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
|
||||
* Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
|
||||
*/
|
||||
$hook = 'jetpack_page_' . $menu_slug;
|
||||
|
||||
// Track the page hook so the design-tokens stylesheet can be scoped to it.
|
||||
self::$page_hooks[] = $hook;
|
||||
|
||||
// Hide WordPress core admin notices on this Jetpack page. The load-<hook>
|
||||
// action only fires when the matching screen is being rendered, so this
|
||||
// stays scoped to Jetpack pages and reaches every page registered here.
|
||||
add_action( 'load-' . $hook, array( __CLASS__, 'hide_core_admin_notices' ) );
|
||||
add_action( 'load-' . $hook . '-network', array( __CLASS__, 'hide_core_admin_notices' ) );
|
||||
|
||||
return $hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an inline style that hides WordPress core admin notices on the current Jetpack page.
|
||||
*
|
||||
* Hooked from the page's load-<hook> action so it only runs on Jetpack screens.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function hide_core_admin_notices() {
|
||||
add_action( 'admin_print_styles', array( __CLASS__, 'print_hide_core_admin_notices_style' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the inline style that hides WordPress core admin notices.
|
||||
*
|
||||
* We only target direct children of #wpbody-content (where core renders notices via the
|
||||
* admin_notices / all_admin_notices hooks). This intentionally leaves JITMs untouched —
|
||||
* they output `.jetpack-jitm-message`, not `.notice` — and leaves in-app/React notices
|
||||
* untouched, since those render deeper inside `.wrap`. An inline style is used (rather than
|
||||
* an enqueued build asset) so it also works on older Jetpack pages that ship no stylesheet.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function print_hide_core_admin_notices_style() {
|
||||
?>
|
||||
<style id="jetpack-admin-ui-hide-core-notices">
|
||||
#wpbody-content > .notice,
|
||||
#wpbody-content > .update-nag,
|
||||
#wpbody-content > .updated,
|
||||
#wpbody-content > .error { display: none !important; }
|
||||
</style>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an already added submenu
|
||||
*
|
||||
* @param string $menu_slug The slug of the submenu to remove.
|
||||
*
|
||||
* @return array|false The removed submenu on success, false if not found.
|
||||
*/
|
||||
public static function remove_menu( $menu_slug ) {
|
||||
|
||||
foreach ( self::$menu_items as $index => $menu_item ) {
|
||||
if ( $menu_item['menu_slug'] === $menu_slug ) {
|
||||
unset( self::$menu_items[ $index ] );
|
||||
|
||||
return $menu_item;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the slug for the first item under the Jetpack top level menu
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public static function get_top_level_menu_item_slug() {
|
||||
global $submenu;
|
||||
if ( ! empty( $submenu['jetpack'] ) ) {
|
||||
$item = reset( $submenu['jetpack'] );
|
||||
if ( isset( $item[2] ) ) {
|
||||
return $item[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the URL for the first item under the Jetpack top level menu
|
||||
*
|
||||
* @param string $fallback If Jetpack menu is not there or no children is found, return this fallback instead. Default to admin_url().
|
||||
* @return string
|
||||
*/
|
||||
public static function get_top_level_menu_item_url( $fallback = false ) {
|
||||
$slug = self::get_top_level_menu_item_slug();
|
||||
|
||||
if ( $slug ) {
|
||||
$url = menu_page_url( $slug, false );
|
||||
return $url;
|
||||
}
|
||||
|
||||
$url = $fallback ? $fallback : admin_url();
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current site should show the upgrade menu item.
|
||||
*
|
||||
* The upgrade menu is only shown to administrators on free-plan sites
|
||||
* that are not hosted on WordPress.com.
|
||||
*
|
||||
* @return bool True if the upgrade menu should be shown.
|
||||
*/
|
||||
private static function should_show_upgrade_menu() {
|
||||
|
||||
// Only show to administrators.
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show upsells on WordPress.com platform.
|
||||
if ( class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
|
||||
$host = new \Automattic\Jetpack\Status\Host();
|
||||
if ( $host->is_wpcom_platform() ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show upsells in offline/development mode.
|
||||
if ( class_exists( '\Automattic\Jetpack\Status' ) ) {
|
||||
$status = new \Automattic\Jetpack\Status();
|
||||
if ( $status->is_offline_mode() ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only show after the site and current user are connected.
|
||||
if ( ! self::is_site_and_user_connected() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show to free-plan sites.
|
||||
return self::is_free_plan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the site and current user are connected to WordPress.com.
|
||||
*
|
||||
* @return bool True if site and current user are connected.
|
||||
*/
|
||||
private static function is_site_and_user_connected() {
|
||||
$connection_manager = self::$connection_manager;
|
||||
if ( ! $connection_manager && class_exists( '\Automattic\Jetpack\Connection\Manager' ) ) {
|
||||
$connection_manager = new \Automattic\Jetpack\Connection\Manager();
|
||||
self::$connection_manager = $connection_manager;
|
||||
}
|
||||
|
||||
if (
|
||||
$connection_manager
|
||||
&& is_callable( array( $connection_manager, 'is_connected' ) )
|
||||
&& is_callable( array( $connection_manager, 'is_user_connected' ) )
|
||||
) {
|
||||
return (bool) $connection_manager->is_connected()
|
||||
&& (bool) $connection_manager->is_user_connected( get_current_user_id() );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the connection manager dependency; used by tests.
|
||||
*
|
||||
* @param object|null $connection_manager Connection manager object.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_connection_manager( $connection_manager ) {
|
||||
self::$connection_manager = $connection_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current site is on a free Jetpack plan with no active paid license.
|
||||
*
|
||||
* @return bool True if the site has no paid plan.
|
||||
*/
|
||||
private static function is_free_plan() {
|
||||
// Check the active plan - use the is_free field or product_slug.
|
||||
$plan = get_option( 'jetpack_active_plan', array() );
|
||||
|
||||
// Back-compat: older plan payloads use class to indicate paid plans.
|
||||
if ( isset( $plan['class'] ) && 'free' !== $plan['class'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the plan explicitly says it's not free, trust that.
|
||||
if ( isset( $plan['is_free'] ) && false === $plan['is_free'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the product slug indicates a paid plan.
|
||||
if ( isset( $plan['product_slug'] ) && 'jetpack_free' !== $plan['product_slug'] ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Also check for site products (licenses can add products without changing plan).
|
||||
$products = get_option( 'jetpack_site_products', array() );
|
||||
if ( ! empty( $products ) && is_array( $products ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally adds an "Upgrade Jetpack" submenu item for free-plan sites.
|
||||
*
|
||||
* Only shown to users with manage_options capability on self-hosted sites without a paid Jetpack plan or license.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function maybe_add_upgrade_menu_item() {
|
||||
if ( ! self::should_show_upgrade_menu() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$upgrade_url = class_exists( '\Automattic\Jetpack\Redirect' )
|
||||
? \Automattic\Jetpack\Redirect::get_url( self::UPGRADE_MENU_SLUG )
|
||||
: self::UPGRADE_MENU_FALLBACK_URL;
|
||||
|
||||
$menu_title = esc_html__( 'Upgrade Jetpack', 'jetpack-admin-ui' );
|
||||
|
||||
add_submenu_page(
|
||||
'jetpack',
|
||||
$menu_title,
|
||||
$menu_title,
|
||||
'manage_options',
|
||||
esc_url( $upgrade_url ),
|
||||
null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
|
||||
999
|
||||
);
|
||||
|
||||
// Add a CSS class to the <li> element so styles can target it precisely.
|
||||
global $submenu;
|
||||
if ( ! empty( $submenu['jetpack'] ) ) {
|
||||
foreach ( $submenu['jetpack'] as $index => $item ) {
|
||||
if ( isset( $item[2] ) && false !== strpos( $item[2], self::UPGRADE_MENU_SLUG ) ) {
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$submenu['jetpack'][ $index ][4] = ( ! empty( $item[4] ) ? $item[4] . ' ' : '' ) . self::UPGRADE_MENU_SLUG;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues admin styles for the "Upgrade Jetpack" menu item.
|
||||
*
|
||||
* The sidebar menu is visible on every admin page, so styles load globally.
|
||||
* Only enqueues for free-plan sites on self-hosted installs.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function add_upgrade_menu_item_styles() {
|
||||
if ( ! self::should_show_upgrade_menu() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$asset_file = dirname( __DIR__ ) . '/build/admin-ui-upgrade-menu.asset.php';
|
||||
$asset = file_exists( $asset_file ) ? require $asset_file : array();
|
||||
|
||||
wp_enqueue_style(
|
||||
'jetpack-admin-ui-upgrade-menu',
|
||||
plugins_url( '../build/admin-ui-upgrade-menu.css', __FILE__ ),
|
||||
$asset['dependencies'] ?? array(),
|
||||
$asset['version'] ?? self::PACKAGE_VERSION
|
||||
);
|
||||
|
||||
self::enqueue_upgrade_menu_tracks_script( $asset );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the shared, token-only WPDS design-tokens stylesheet.
|
||||
*
|
||||
* Single entry point for any consumer that needs WPDS `var(--wpds-*)` values
|
||||
* to resolve at runtime on a Jetpack admin page. Registers the handle on
|
||||
* first use (idempotent) and enqueues it; the caller is responsible for
|
||||
* scoping the call to the right page(s). Since admin-ui is a dependency of
|
||||
* the Jetpack plugin and the modernized packages, both the plugin's
|
||||
* legacy/wrap_ui gate and this package's own dashboards call through here,
|
||||
* so the handle has a single owner and there is no duplicated enqueue logic.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function enqueue_design_tokens() {
|
||||
self::register_design_tokens_style();
|
||||
wp_enqueue_style( self::DESIGN_TOKENS_HANDLE );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the shared, token-only WPDS design-tokens stylesheet.
|
||||
*
|
||||
* The stylesheet only defines `:root{--wpds-*}` custom properties (no
|
||||
* component or class styles), giving every Jetpack admin page a single
|
||||
* runtime source for design tokens. It is safe to call repeatedly:
|
||||
* wp_register_style() is a no-op once the handle is registered.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_design_tokens_style() {
|
||||
if ( wp_style_is( self::DESIGN_TOKENS_HANDLE, 'registered' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$asset_file = dirname( __DIR__ ) . '/build/design-tokens.asset.php';
|
||||
$asset = file_exists( $asset_file ) ? require $asset_file : array();
|
||||
|
||||
wp_register_style(
|
||||
self::DESIGN_TOKENS_HANDLE,
|
||||
plugins_url( '../build/design-tokens.css', __FILE__ ),
|
||||
$asset['dependencies'] ?? array(),
|
||||
$asset['version'] ?? self::PACKAGE_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the design tokens on the pages registered through this class.
|
||||
*
|
||||
* This is the admin_enqueue_scripts callback for the modernized Jetpack
|
||||
* dashboards. Scoped to self::$page_hooks so the tokens load wherever a
|
||||
* modernized dashboard renders, regardless of plan or connection state; the
|
||||
* actual enqueue is delegated to the reusable enqueue_design_tokens() API.
|
||||
*
|
||||
* @param string $hook_suffix The current admin page's hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public static function maybe_enqueue_design_tokens( $hook_suffix ) {
|
||||
if ( ! in_array( $hook_suffix, self::$page_hooks, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::enqueue_design_tokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues Tracks for the upgrade submenu item.
|
||||
*
|
||||
* @param array $asset Parsed contents of admin-ui-upgrade-menu.asset.php.
|
||||
* @return void
|
||||
*/
|
||||
private static function enqueue_upgrade_menu_tracks_script( $asset ) {
|
||||
if ( ! class_exists( '\Automattic\Jetpack\Tracking' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
Tracking::register_tracks_functions_scripts( true );
|
||||
|
||||
wp_enqueue_script(
|
||||
'jetpack-admin-ui-upgrade-menu-tracking',
|
||||
plugins_url( '../build/admin-ui-upgrade-menu-tracking.js', __FILE__ ),
|
||||
$asset['dependencies'] ?? array(),
|
||||
$asset['version'] ?? self::PACKAGE_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
$current_screen = get_current_screen();
|
||||
$is_admin = current_user_can( 'jetpack_disconnect' );
|
||||
$site_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
|
||||
$tracks_user_data = class_exists( 'Jetpack_Tracks_Client' ) ? Jetpack_Tracks_Client::get_connected_user_tracks_identity() : null;
|
||||
|
||||
wp_localize_script(
|
||||
'jetpack-admin-ui-upgrade-menu-tracking',
|
||||
'jetpackAdminUiUpgradeMenu',
|
||||
array(
|
||||
'menuItemClass' => self::UPGRADE_MENU_SLUG,
|
||||
'tracksUserData' => $tracks_user_data,
|
||||
'tracksEventData' => array(
|
||||
'is_admin' => $is_admin,
|
||||
'current_screen' => $current_screen ? $current_screen->id : false,
|
||||
'blog_id' => $site_id,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Token-only WPDS design-tokens stylesheet.
|
||||
*
|
||||
* Re-exports the `:root{--wpds-*}` custom-property definitions from
|
||||
* @wordpress/theme so they can be enqueued as a standalone WP style handle on
|
||||
* every Jetpack admin page. The import is resolved and inlined by css-loader at
|
||||
* build time (a local entry file is used rather than pointing the webpack entry
|
||||
* straight at the package, which would get externalized to a non-existent
|
||||
* `wp-theme/design-tokens.css` script handle).
|
||||
*
|
||||
* Keep this token-only: do not add component or class selectors here.
|
||||
*/
|
||||
@import "@wordpress/theme/design-tokens.css";
|
||||
@@ -0,0 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.0] - 2026-06-29
|
||||
### Added
|
||||
- Add Ask AI and Help entry points to the block editor omnibar. [#49967]
|
||||
|
||||
## [0.5.3] - 2026-06-26
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.5.2] - 2026-06-23
|
||||
### Changed
|
||||
- Update package dependencies. [#49831]
|
||||
|
||||
## [0.5.1] - 2026-06-22
|
||||
### Changed
|
||||
- Update package dependencies. [#49691] [#49757]
|
||||
|
||||
### Fixed
|
||||
- Dequeue Help Center only in the block editor when the full unified experience is active, so Help Center stays available in block-editor-only mode. [#49750]
|
||||
|
||||
## [0.5.0] - 2026-06-15
|
||||
### Changed
|
||||
- Update package dependencies. [#49631]
|
||||
|
||||
### Fixed
|
||||
- Agents Manager: Bootstrap hooks exactly once even if multiple versions of the class are shipped. [#49636]
|
||||
- Agents Manager: drive the sidebar pre-render from the persisted open state (cached in a transient) instead of a path-scoped cookie, and only pre-render where the app is actually loaded, so closing the assistant on another domain no longer leaves a stale sidebar shell behind. [#49439]
|
||||
|
||||
## [0.4.0] - 2026-06-15
|
||||
### Added
|
||||
- Add a standalone AI chat button to the admin bar. [#49455]
|
||||
- Persist the Agents Manager minimized and last-activity state via the open-state endpoint. [#49565]
|
||||
|
||||
## [0.3.2] - 2026-06-10
|
||||
### Changed
|
||||
- Update package dependencies. [#49273] [#49492]
|
||||
|
||||
## [0.3.1] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.0] - 2026-06-05
|
||||
### Added
|
||||
- Agents Manager: Add the jetpack-ai-jwt REST endpoint, moved from the My Jetpack package. [#49415]
|
||||
|
||||
## [0.2.1] - 2026-06-03
|
||||
### Fixed
|
||||
- Agents Manager: Include build folder when pushing changes to mirror repo. [#49383]
|
||||
|
||||
## [0.2.0] - 2026-06-03
|
||||
### Added
|
||||
- Agents Manager: Ensure sidebar preserves open state on load. [#49325]
|
||||
|
||||
## 0.1.0 - 2026-06-02
|
||||
### Added
|
||||
- Agents Manager: Allow overriding variant and sectionName through filters [#49283]
|
||||
- Initial version, extracted from Jetpack MU WPCOM to its own package for external consumption. [#49202]
|
||||
|
||||
[0.6.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.3...v0.6.0
|
||||
[0.5.3]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.2...v0.4.0
|
||||
[0.3.2]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.2.1...v0.3.0
|
||||
[0.2.1]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/jetpack-agents-manager/compare/v0.1.0...v0.2.0
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array(), 'version' => '20c99af888eb66732839');
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{const e=["post-php","post-new-php","site-editor-php"],n=["agents-manager-sidebar-container","agents-manager-sidebar-container--sidebar-open"];function t(){const{classList:n}=document.body;return!e.some(e=>n.contains(e))||n.contains("is-fullscreen-mode")}function o(){document.body.classList.remove(...n)}if(function(){if(!document.body)return;const e=document.getElementById("adminmenu");if(!e)return;const n=document.getElementById("wpadminbar"),s=n?n.offsetHeight:32,i=window.innerHeight<e.offsetHeight+s+20,c=window.innerWidth<1200;(i||c||!t())&&o()}(),document.body){const e=new MutationObserver(()=>{n.some(e=>document.body.classList.contains(e))&&!document.querySelector(".agents-manager-chat")?t()||o():e.disconnect()});e.observe(document.body,{attributes:!0,attributeFilter:["class"]})}})();
|
||||
@@ -0,0 +1,998 @@
|
||||
<?php
|
||||
/**
|
||||
* Agents manager
|
||||
*
|
||||
* @package automattic/jetpack-agents-manager
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Agents_Manager;
|
||||
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Constants;
|
||||
|
||||
/**
|
||||
* Class Agents_Manager
|
||||
*/
|
||||
class Agents_Manager {
|
||||
/**
|
||||
* The package version of the Agents Manager package.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PACKAGE_VERSION = '0.6.0';
|
||||
|
||||
/**
|
||||
* Help Center URL for disconnected variants.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const HELP_CENTER_URL = 'https://wordpress.com/help?help-center=home';
|
||||
|
||||
/**
|
||||
* Class instance.
|
||||
*
|
||||
* @var Agents_Manager
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Agents_Manager constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
add_action( 'rest_api_init', array( $this, 'register_rest_api' ) );
|
||||
add_filter( 'calypso_preferences_update', array( $this, 'calypso_preferences_update' ) );
|
||||
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
|
||||
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ), 101 );
|
||||
add_action( 'next_admin_init', array( $this, 'enqueue_scripts' ), 1001 );
|
||||
add_filter( 'agents_manager_use_unified_experience', array( $this, 'should_use_unified_experience' ) );
|
||||
|
||||
Sidebar_Open_Preservation::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the agents manager menu panel should be displayed.
|
||||
*
|
||||
* @return bool True if the menu panel should be displayed.
|
||||
*/
|
||||
public function should_display_menu_panel() {
|
||||
return self::is_unified_experience();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SVG icon markup for a given icon name.
|
||||
*
|
||||
* @param string $icon_name The name of the icon to retrieve.
|
||||
* @return string The SVG markup.
|
||||
*/
|
||||
private function get_icon( $icon_name ) {
|
||||
$icons = array(
|
||||
'comment' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H6c-1.1 0-2 .9-2 2v12.9c0 .6.5 1.1 1.1 1.1.3 0 .5-.1.8-.3L8.5 17H18c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm.5 11c0 .3-.2.5-.5.5H7.9l-2.4 2.4V6c0-.3.2-.5.5-.5h12c.3 0 .5.2.5.5v9z" /></svg>',
|
||||
'backup' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.5 12h1.75l-2.5 3-2.5-3H4a8 8 0 113.134 6.35l.907-1.194A6.5 6.5 0 105.5 12zm9.53 1.97l-2.28-2.28V8.5a.75.75 0 00-1.5 0V12a.747.747 0 00.218.529l1.282-.84-1.28.842 2.5 2.5a.75.75 0 101.06-1.061z" /></svg>',
|
||||
'page' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M15.5 7.5h-7V9h7V7.5Zm-7 3.5h7v1.5h-7V11Zm7 3.5h-7V16h7v-1.5Z" /><path d="M17 4H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2ZM7 5.5h10a.5.5 0 0 1 .5.5v12a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5Z" /></svg>',
|
||||
'video' => '<svg class="help-center-menu-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.7 3H5.3C4 3 3 4 3 5.3v13.4C3 20 4 21 5.3 21h13.4c1.3 0 2.3-1 2.3-2.3V5.3C21 4 20 3 18.7 3zm.8 15.7c0 .4-.4.8-.8.8H5.3c-.4 0-.8-.4-.8-.8V5.3c0-.4.4-.8.8-.8h13.4c.4 0 .8.4.8.8v13.4zM10 15l5-3-5-3v6z" /></svg>',
|
||||
'rss' => '<svg class="help-center-menu-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 10.2h-.8v1.5H5c1.9 0 3.8.8 5.1 2.1 1.4 1.4 2.1 3.2 2.1 5.1v.8h1.5V19c0-2.3-.9-4.5-2.6-6.2-1.6-1.6-3.8-2.6-6.1-2.6zm10.4-1.6C12.6 5.8 8.9 4.2 5 4.2h-.8v1.5H5c3.5 0 6.9 1.4 9.4 3.9s3.9 5.8 3.9 9.4v.8h1.5V19c0-3.9-1.6-7.6-4.4-10.4zM4 20h3v-3H4v3z" /></svg>',
|
||||
);
|
||||
|
||||
return $icons[ $icon_name ] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Agents Manager Help "?" node (`agents-manager`) to the admin bar, replacing the
|
||||
* legacy Help Center node (`help-center`).
|
||||
*
|
||||
* @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
|
||||
* @param bool $use_disconnected Disconnected variants link straight to the Help Center instead of opening the dropdown.
|
||||
*/
|
||||
public function add_help_menu( $wp_admin_bar, $use_disconnected ) {
|
||||
$wp_admin_bar->remove_node( 'help-center' );
|
||||
|
||||
$menu_args = array(
|
||||
'id' => 'agents-manager',
|
||||
'title' => '<span title="' . esc_attr__( 'Help Center', 'jetpack-agents-manager' ) . '"><svg id="agents-manager-icon" class="ab-icon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 16v-2h2v2h-2zm2-3v-1.141A3.991 3.991 0 0016 10a4 4 0 00-8 0h2c0-1.103.897-2 2-2s2 .897 2 2-.897 2-2 2a1 1 0 00-1 1v2h2z" />
|
||||
</svg></span>',
|
||||
'parent' => 'top-secondary',
|
||||
);
|
||||
|
||||
if ( $use_disconnected ) {
|
||||
$menu_args['href'] = self::HELP_CENTER_URL;
|
||||
$menu_args['meta'] = array(
|
||||
'target' => '_blank',
|
||||
'rel' => 'noopener noreferrer',
|
||||
);
|
||||
} else {
|
||||
$menu_args['meta'] = array(
|
||||
'html' => '<div id="agents-manager-masterbar"></div>',
|
||||
'class' => 'menupop',
|
||||
'target' => '_blank',
|
||||
'rel' => 'noopener noreferrer',
|
||||
);
|
||||
}
|
||||
|
||||
$wp_admin_bar->add_menu( $menu_args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the agents manager menu panel to the admin bar.
|
||||
*
|
||||
* @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
|
||||
*/
|
||||
public function add_menu_panel( $wp_admin_bar ) {
|
||||
// Add chat support group
|
||||
$wp_admin_bar->add_group(
|
||||
array(
|
||||
'parent' => 'agents-manager',
|
||||
'id' => 'agents-manager-menu-panel-chat',
|
||||
'meta' => array(
|
||||
'class' => 'ab-sub-secondary',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Add chat support menu item
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'parent' => 'agents-manager-menu-panel-chat',
|
||||
'id' => 'agents-manager-chat-support',
|
||||
'title' => $this->get_icon( 'comment' ) . '<span>' . __( 'Chat support', 'jetpack-agents-manager' ) . '</span>',
|
||||
)
|
||||
);
|
||||
|
||||
// Add chat history menu item
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'parent' => 'agents-manager-menu-panel-chat',
|
||||
'id' => 'agents-manager-chat-history',
|
||||
'title' => $this->get_icon( 'backup' ) . '<span>' . __( 'Chat history', 'jetpack-agents-manager' ) . '</span>',
|
||||
)
|
||||
);
|
||||
|
||||
// Add links group
|
||||
$wp_admin_bar->add_group(
|
||||
array(
|
||||
'parent' => 'agents-manager',
|
||||
'id' => 'agents-manager-menu-panel-links',
|
||||
'meta' => array(
|
||||
'class' => 'ab-sub-secondary',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Add support guides menu item
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'parent' => 'agents-manager-menu-panel-links',
|
||||
'id' => 'agents-manager-support-guides',
|
||||
'title' => $this->get_icon( 'page' ) . '<span>' . __( 'Support guides', 'jetpack-agents-manager' ) . '</span>',
|
||||
)
|
||||
);
|
||||
|
||||
// Add courses menu item
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'parent' => 'agents-manager-menu-panel-links',
|
||||
'id' => 'agents-manager-courses',
|
||||
'title' => $this->get_icon( 'video' ) . '<span>' . __( 'Courses', 'jetpack-agents-manager' ) . '</span>',
|
||||
'href' => 'https://wordpress.com/support/courses/',
|
||||
'meta' => array(
|
||||
'target' => '_blank',
|
||||
'rel' => 'noopener noreferrer',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Add product updates menu item
|
||||
$wp_admin_bar->add_node(
|
||||
array(
|
||||
'parent' => 'agents-manager-menu-panel-links',
|
||||
'id' => 'agents-manager-product-updates',
|
||||
'title' => $this->get_icon( 'rss' ) . '<span>' . __( 'Product updates', 'jetpack-agents-manager' ) . '</span>',
|
||||
'href' => 'https://wordpress.com/blog/category/product-features/',
|
||||
'meta' => array(
|
||||
'target' => '_blank',
|
||||
'rel' => 'noopener noreferrer',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the standalone AI chat button to the admin bar.
|
||||
*
|
||||
* @param \WP_Admin_Bar $wp_admin_bar The WP_Admin_Bar instance.
|
||||
*/
|
||||
public function add_ai_chat_button( $wp_admin_bar ) {
|
||||
$wp_admin_bar->add_menu(
|
||||
array(
|
||||
'id' => 'agents-manager-ai-chat',
|
||||
'parent' => 'top-secondary',
|
||||
'title' => '<span title="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '"><svg class="ab-icon" role="img" aria-label="' . esc_attr__( 'Ask AI', 'jetpack-agents-manager' ) . '" width="24" height="24" viewBox="-45 -45 490 490" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor" d="M391.528 188.061L309.455 159.75C276.997 148.597 251.403 123.003 240.25 90.5451L211.939 8.47185C208.079 -2.82395 191.921 -2.82395 188.061 8.47185L159.75 90.5451C148.597 123.003 123.003 148.597 90.5451 159.75L8.47185 188.061C-2.82395 191.921 -2.82395 208.079 8.47185 211.939L90.5451 240.25C123.003 251.403 148.597 276.997 159.75 309.455L188.061 391.528C191.921 402.824 208.079 402.824 211.939 391.528L240.25 309.455C251.403 276.997 276.997 251.403 309.455 240.25L391.528 211.939C402.824 208.079 402.824 191.921 391.528 188.061ZM295.728 206.077L254.692 220.232C238.391 225.809 225.666 238.677 220.089 254.835L205.934 295.871C203.932 301.591 195.925 301.591 193.923 295.871L179.768 254.835C174.191 238.534 161.323 225.809 145.165 220.232L104.129 206.077C98.4093 204.075 98.4093 196.068 104.129 194.066L145.165 179.911C161.466 174.334 174.191 161.466 179.768 145.308L193.923 104.272C195.925 98.5523 203.932 98.5523 205.934 104.272L220.089 145.308C225.666 161.609 238.534 174.334 254.692 179.911L295.728 194.066C301.448 196.068 301.448 204.075 295.728 206.077Z" />
|
||||
</svg></span>',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Agents Manager scripts and add inline script data.
|
||||
*/
|
||||
public function enqueue_scripts() {
|
||||
// Early return for P2 frontend - don't add admin bar or enqueue scripts.
|
||||
$stylesheet = get_stylesheet();
|
||||
$is_p2 = str_contains( $stylesheet, 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && \WPForTeams\is_wpforteams_site( get_current_blog_id() );
|
||||
|
||||
if ( ! is_admin() && $is_p2 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which variant to load (null = don't load).
|
||||
$variant = self::get_active_variant();
|
||||
if ( null === $variant ) {
|
||||
return;
|
||||
}
|
||||
$use_disconnected = str_contains( $variant, 'disconnected' );
|
||||
$is_gutenberg = $this->is_block_editor();
|
||||
|
||||
// In Gutenberg, dequeue Help Center so we don't end up with two buttons — but only
|
||||
// in the full unified experience, where Agents Manager takes over the Help Center.
|
||||
// In block-editor-only mode (e.g. ?flags=unified-big-sky) Agents Manager replaces
|
||||
// Big Sky's native UI and Help Center should remain available.
|
||||
// Agents Manager fires at priority 101, after Help Center at 100, so HC is already enqueued.
|
||||
if ( $is_gutenberg && self::is_unified_experience() ) {
|
||||
wp_dequeue_script( 'help-center' );
|
||||
wp_dequeue_style( 'help-center-style' );
|
||||
}
|
||||
|
||||
// For non-Gutenberg, non-CIAB environments, add to the admin bar. The fullscreen Gutenberg
|
||||
// editor has no admin bar, so JS handles UI insertion — except under the omnibar, which is
|
||||
// handled below. CIAB hides the admin bar and uses its own Site Hub.
|
||||
$is_ciab = $this->is_ciab_environment();
|
||||
if ( ! $is_gutenberg && ! $is_ciab ) {
|
||||
add_action(
|
||||
'admin_bar_menu',
|
||||
function ( $wp_admin_bar ) use ( $use_disconnected ) {
|
||||
$this->add_help_menu( $wp_admin_bar, $use_disconnected );
|
||||
},
|
||||
// Add the agents manager icon to the admin bar after the help center is added, so we can remove it.
|
||||
100
|
||||
);
|
||||
|
||||
// Initialize the agents manager menu panel (only for full variants, not disconnected)
|
||||
if ( ! $use_disconnected ) {
|
||||
add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
|
||||
}
|
||||
|
||||
// Standalone AI chat button, shown only in the unified experience.
|
||||
if ( ! $use_disconnected && self::is_unified_experience() ) {
|
||||
add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
|
||||
}
|
||||
}
|
||||
|
||||
// When Gutenberg's "admin bar in editor" (omnibar) experiment is active, expose the entry
|
||||
// points in that editor admin bar (CIAB is excluded — it has its own Site Hub UI). The Help
|
||||
// "?" dropdown shows only in the full unified experience (mirroring wp-admin); the Ask AI
|
||||
// button shows in any dev/internal context while the feature is in development. The
|
||||
// wp-calypso admin-bar integration wires both, so no frontend change is needed.
|
||||
if ( ! $is_ciab && ! $use_disconnected && self::is_admin_bar_in_editor() ) {
|
||||
// Help "?" node + dropdown panel first, matching the wp-admin admin bar order.
|
||||
if ( self::is_unified_experience() ) {
|
||||
add_action(
|
||||
'admin_bar_menu',
|
||||
function ( $wp_admin_bar ) {
|
||||
$this->add_help_menu( $wp_admin_bar, false );
|
||||
},
|
||||
100
|
||||
);
|
||||
add_action( 'admin_bar_menu', array( $this, 'add_menu_panel' ), 100 );
|
||||
}
|
||||
|
||||
// Ask AI button — dev/internal contexts only while the feature is in development.
|
||||
if ( self::is_dev_mode() ) {
|
||||
add_action( 'admin_bar_menu', array( $this, 'add_ai_chat_button' ), 100 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to register agent provider modules for the Agents Manager.
|
||||
*
|
||||
* Plugins can hook into this filter to register script module IDs that export
|
||||
* toolProvider and/or contextProvider. The Agents Manager JS will dynamically
|
||||
* import these modules and merge their providers.
|
||||
*
|
||||
* @param array $providers Array of provider script module IDs.
|
||||
*/
|
||||
$agent_providers = apply_filters( 'agents_manager_agent_providers', array() );
|
||||
|
||||
$use_unified_experience = self::is_unified_experience();
|
||||
|
||||
/**
|
||||
* Filter the default agent ID for the Agents Manager.
|
||||
*
|
||||
* Allows host applications (e.g., CIAB, WooCommerce AI) to specify a custom
|
||||
* workflow agent instead of the default orchestrator. The value is passed to
|
||||
* the frontend as `agentsManagerData.agentId` and consumed by `useAgentConfig()`.
|
||||
*
|
||||
* @param string|null $agent_id The agent ID to use, or null for default behavior.
|
||||
*/
|
||||
$agent_id = apply_filters( 'agents_manager_agent_id', null );
|
||||
|
||||
$this->enqueue_script( $variant );
|
||||
|
||||
$inline_data = array(
|
||||
'agentProviders' => $agent_providers,
|
||||
'useUnifiedExperience' => $use_unified_experience,
|
||||
'isDevMode' => self::is_dev_mode(),
|
||||
'sectionName' => apply_filters( 'agents_manager_section_name', $variant ),
|
||||
'currentUser' => $this->get_current_user_data(),
|
||||
'site' => $this->get_current_site(),
|
||||
'helpCenterUrl' => self::HELP_CENTER_URL,
|
||||
);
|
||||
|
||||
if ( $agent_id ) {
|
||||
$inline_data['agentId'] = $agent_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the data exposed to the Agents Manager frontend.
|
||||
*
|
||||
* @param array $inline_data Data encoded into `agentsManagerData`.
|
||||
*/
|
||||
$filtered = apply_filters( 'jetpack_ai_sidebar_agents_manager_data', $inline_data );
|
||||
$inline_data = is_array( $filtered ) ? $filtered : $inline_data;
|
||||
|
||||
wp_add_inline_script(
|
||||
'agents-manager',
|
||||
'const agentsManagerData = ' . wp_json_encode(
|
||||
$inline_data,
|
||||
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
|
||||
) . ';',
|
||||
'before'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The script variant active for this request, or null if none.
|
||||
*
|
||||
* Single source of truth for "is the Agents Manager app loaded on this
|
||||
* request?". Used both to enqueue the app and to gate the server-side
|
||||
* sidebar pre-render, so the pre-rendered shell can never appear on a page
|
||||
* where the app won't mount to reconcile it.
|
||||
*
|
||||
* @return string|null The variant name, or null if scripts should not be loaded.
|
||||
*/
|
||||
public static function get_active_variant() {
|
||||
/**
|
||||
* Filter the script variant the Agents Manager loads for this request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*
|
||||
* @param string|null $variant The resolved variant, or null to not load.
|
||||
*/
|
||||
return apply_filters( 'agents_manager_variant', self::get_variant() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which script variant to load, or null if none should be loaded.
|
||||
*
|
||||
* Combines the gating logic (should we load at all?) with variant selection
|
||||
* (which build to use?) into a single method so the two cannot get out of sync.
|
||||
*
|
||||
* @return string|null The variant name, or null if scripts should not be loaded.
|
||||
*/
|
||||
private static function get_variant() {
|
||||
// CIAB: Load either the connected or disconnected variants if enabled.
|
||||
if ( self::is_ciab_environment() && self::is_enabled() ) {
|
||||
return self::is_jetpack_disconnected() ? 'ciab-disconnected' : 'ciab';
|
||||
}
|
||||
|
||||
// Frontend: load disconnected variant for eligible logged-in editors.
|
||||
if ( ! is_admin() ) {
|
||||
if ( self::is_loading_on_frontend() && self::is_enabled() ) {
|
||||
return 'wp-admin-disconnected';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply wp-admin exclusions (WooCommerce, customizer, preview contexts).
|
||||
if ( ! self::passes_admin_checks() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( ! self::is_enabled() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disconnected = self::is_jetpack_disconnected();
|
||||
|
||||
if ( self::is_block_editor() ) {
|
||||
return $disconnected ? 'gutenberg-disconnected' : 'gutenberg';
|
||||
}
|
||||
|
||||
return $disconnected ? 'wp-admin-disconnected' : 'wp-admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the unified experience — the Help Center takeover — is active.
|
||||
*
|
||||
* "Unified" here means Agents Manager takes over the Help Center, unifying Odie and
|
||||
* Dolly (the orchestrator) into a single chat experience. This is distinct from
|
||||
* block-editor-only enablement, which replaces Big Sky's native UI without taking
|
||||
* over the Help Center.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_unified_experience() {
|
||||
/**
|
||||
* Filter to determine if the user should see the unified chat experience.
|
||||
*
|
||||
* When true, Help Center will render UnifiedAIAgent instead of traditional UI.
|
||||
* The filter is hooked by should_use_unified_experience() in this class.
|
||||
*
|
||||
* @param bool $use_unified_experience Whether to use unified experience. Default false.
|
||||
*/
|
||||
return (bool) apply_filters( 'agents_manager_use_unified_experience', false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the Agents Manager should be loaded in the current context.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
// CIAB: Agents Manager is the default AI experience — enabled unless explicitly
|
||||
// disabled via filter (e.g. for debugging or gradual rollout).
|
||||
if ( self::is_ciab_environment() ) {
|
||||
/**
|
||||
* Filter whether Agents Manager is enabled in CIAB (Next Admin) environments.
|
||||
*
|
||||
* @param bool $enabled Whether Agents Manager should load. Default true.
|
||||
*/
|
||||
return apply_filters( 'agents_manager_enabled_in_ciab', true );
|
||||
}
|
||||
|
||||
// Full unified experience: Agents Manager with support guides, Help Center takeover, etc.
|
||||
if ( self::is_unified_experience() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Block editor only: Agents Manager replaces Big Sky's native UI. Hooked by Big Sky.
|
||||
if ( self::is_block_editor() && apply_filters( 'agents_manager_enabled_in_block_editor', false ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current wp-admin context passes all exclusion checks.
|
||||
*
|
||||
* Excludes WooCommerce Admin home, customizer preview, Gutenberg asset requests,
|
||||
* and preview query param contexts.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function passes_admin_checks() {
|
||||
// Don't load on WooCommerce Admin home page to avoid UI conflicts.
|
||||
global $current_screen;
|
||||
if ( $current_screen && $current_screen->id === 'woocommerce_page_wc-admin' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't load in customizer preview iframe.
|
||||
if ( is_customize_preview() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't load during Gutenberg asset requests or preview contexts.
|
||||
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
|
||||
$is_preview = isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) );
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
|
||||
$is_preview_overlay = isset( $_GET['preview_overlay'] );
|
||||
if ( str_contains( $request_uri, 'wp-content/plugins/gutenberg-core' ) || $is_preview || $is_preview_overlay ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Agents Manager script based on context.
|
||||
*
|
||||
* @param string $variant The variant of the asset file to get.
|
||||
*/
|
||||
private function enqueue_script( $variant ) {
|
||||
$cache_key = 'agents-manager-asset-' . $variant . '.asset.json';
|
||||
$asset_file = get_transient( $cache_key );
|
||||
|
||||
if ( ! $asset_file ) {
|
||||
$asset_file = self::get_assets_json( 'widgets.wp.com/agents-manager/agents-manager-' . $variant . '.asset.json' );
|
||||
if ( ! $asset_file ) {
|
||||
return;
|
||||
}
|
||||
set_transient( $cache_key, $asset_file, HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
// When the request is dev mode, use a random cache buster as the version for easier debugging.
|
||||
$version = self::is_dev_mode() ? wp_rand() : $asset_file['version'];
|
||||
|
||||
$script_dependencies = $asset_file['dependencies'] ?? array();
|
||||
|
||||
wp_enqueue_script(
|
||||
'agents-manager',
|
||||
'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . '.min.js',
|
||||
$script_dependencies,
|
||||
$version,
|
||||
/**
|
||||
* Filter the strategy to use when enqueuing the script.
|
||||
*
|
||||
* @param array|bool $args The arguments to pass to wp_enqueue_script. Default is true.
|
||||
* @param string $handle The handle of the script.
|
||||
*/
|
||||
apply_filters( 'agents_manager_enqueue_script_strategy', true, 'agents-manager' )
|
||||
);
|
||||
|
||||
if ( 'gutenberg-disconnected' !== $variant && 'ciab-disconnected' !== $variant ) {
|
||||
wp_enqueue_style(
|
||||
'agents-manager-style',
|
||||
'https://widgets.wp.com/agents-manager/agents-manager-' . $variant . ( is_rtl() ? '.rtl.css' : '.css' ),
|
||||
array(),
|
||||
$version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the asset via file-system on wpcom and via network on Atomic sites.
|
||||
*
|
||||
* @param string $filepath The URL to download the asset file from.
|
||||
* @return array|null The asset file data or null on failure.
|
||||
*/
|
||||
private static function get_assets_json( $filepath ) {
|
||||
$accessible_directly = file_exists( ABSPATH . $filepath );
|
||||
|
||||
if ( $accessible_directly ) {
|
||||
$file_contents = file_get_contents( ABSPATH . $filepath );
|
||||
|
||||
if ( false === $file_contents ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode( $file_contents, true );
|
||||
}
|
||||
|
||||
$request = wp_remote_get( 'https://' . $filepath );
|
||||
|
||||
if ( is_wp_error( $request ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code( $request );
|
||||
if ( 200 !== $response_code ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content_type = wp_remote_retrieve_header( $request, 'content-type' );
|
||||
if ( is_string( $content_type ) && false === strpos( $content_type, 'json' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $request );
|
||||
if ( '' === $body ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode( $body, true );
|
||||
if ( json_last_error() !== JSON_ERROR_NONE ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the calypso preferences.
|
||||
*
|
||||
* @param \stdClass $preferences The preferences.
|
||||
*
|
||||
* @return \stdClass The preferences.
|
||||
*/
|
||||
public function calypso_preferences_update( $preferences ) {
|
||||
// Check if agents_manager_router_history exists and is a valid array structure
|
||||
if ( ! isset( $preferences->agents_manager_router_history ) ||
|
||||
! is_array( $preferences->agents_manager_router_history ) ) {
|
||||
return $preferences;
|
||||
}
|
||||
|
||||
$router_history = $preferences->agents_manager_router_history;
|
||||
|
||||
// Check if entries exist and is an array
|
||||
if ( ! isset( $router_history['entries'] ) ||
|
||||
! is_array( $router_history['entries'] ) ) {
|
||||
return $preferences;
|
||||
}
|
||||
|
||||
$entries = $router_history['entries'];
|
||||
|
||||
// Limit entries to 50 to prevent spamming entries in the router history.
|
||||
if ( count( $entries ) > 50 ) {
|
||||
// Keep only the last 49 entries and add the root entry at the beginning.
|
||||
$entries = array_slice( $entries, -49 );
|
||||
// Keep the start at root so the back button always works.
|
||||
array_unshift(
|
||||
$entries,
|
||||
array(
|
||||
'pathname' => '/',
|
||||
'search' => '',
|
||||
'hash' => '',
|
||||
'key' => 'default',
|
||||
'state' => null,
|
||||
)
|
||||
);
|
||||
|
||||
// Update the preferences object directly
|
||||
$preferences->agents_manager_router_history['entries'] = $entries;
|
||||
$preferences->agents_manager_router_history['index'] = 49;
|
||||
}
|
||||
|
||||
return $preferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates instance.
|
||||
*
|
||||
* @return Agents_Manager
|
||||
*/
|
||||
public static function init() {
|
||||
if ( did_action( 'jetpack_agents_manager_initialized' ) ) {
|
||||
return self::get_instance();
|
||||
}
|
||||
|
||||
self::$instance = new self();
|
||||
|
||||
/**
|
||||
* Fires once the Agents Manager class has been instantiated.
|
||||
*
|
||||
* @since 0.5.0
|
||||
*/
|
||||
do_action( 'jetpack_agents_manager_initialized' );
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the instance of the Agents Manager class.
|
||||
*
|
||||
* @return Agents_Manager
|
||||
*/
|
||||
public static function get_instance() {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current request is coming from the A8C proxy.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_proxied() {
|
||||
// On Simple sites, use the wpcom function if available.
|
||||
if ( function_exists( 'wpcom_is_proxied_request' ) ) {
|
||||
return wpcom_is_proxied_request();
|
||||
}
|
||||
|
||||
// On WoA/Garden sites, check server variable or constant.
|
||||
return isset( $_SERVER['A8C_PROXIED_REQUEST'] )
|
||||
? (bool) sanitize_text_field( wp_unslash( $_SERVER['A8C_PROXIED_REQUEST'] ) )
|
||||
: Constants::is_true( 'A8C_PROXIED_REQUEST' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables "Development" features that should be accessible only for admins.
|
||||
*/
|
||||
private static function is_dev_mode() {
|
||||
// Known local environments.
|
||||
$domain = wp_parse_url( get_site_url(), PHP_URL_HOST );
|
||||
if (
|
||||
$domain === 'localhost' ||
|
||||
'.jurassic.tube' === stristr( $domain, '.jurassic.tube' ) ||
|
||||
'.jurassic.ninja' === stristr( $domain, '.jurassic.ninja' )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A8C development.
|
||||
if ( self::is_proxied() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ( Constants::is_true( 'AT_PROXIED_REQUEST' ) && Constants::is_defined( 'ATOMIC_CLIENT_ID' ) ) {
|
||||
switch ( Constants::get_constant( 'ATOMIC_CLIENT_ID' ) ) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 3: // Pressable
|
||||
case 32:
|
||||
case 118: // Commerce garden client (ciab)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Agents Manager endpoints.
|
||||
*/
|
||||
public function register_rest_api() {
|
||||
( new WP_REST_Agents_Manager_Persisted_Open_State() )->register_rest_route();
|
||||
( new WP_REST_Jetpack_AI_JWT() )->register_rest_route();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if user should see unified experience.
|
||||
*
|
||||
* @param bool $use_unified_experience Whether to use unified experience.
|
||||
* @return bool
|
||||
*/
|
||||
public function should_use_unified_experience( $use_unified_experience = false ) {
|
||||
// Early return for non-proxied/dev mode requests.
|
||||
// This feature is currently only available to Automattic employees testing via proxy.
|
||||
if ( ! self::is_dev_mode() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
if ( ! $user_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$is_simple_site = ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple();
|
||||
if ( $is_simple_site ) {
|
||||
// On Simple sites, evaluate locally.
|
||||
// Check Automattician and opt-in setting.
|
||||
$is_automattician = function_exists( '\is_automattician' ) && \is_automattician( $user_id );
|
||||
if ( $is_automattician && $this->has_unified_chat_opt_in_enabled( $user_id ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// On WoA and Garden sites, delegate to wpcom via the /agents-manager/state endpoint.
|
||||
// This avoids duplicating rollout logic and handles cases where
|
||||
// wpcom-specific functions (like get_user_attribute) aren't available.
|
||||
if ( $this->fetch_unified_experience_preference() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default to false, for now.
|
||||
// In the future: users with a big sky site (similar to https://github.a8c.com/Automattic/wpcom/pull/196449/files), a big-sky free trial or a paid plan.
|
||||
return $use_unified_experience;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has enabled unified chat opt-in in their Automattician options.
|
||||
*
|
||||
* This checks the unified_ai_chat calypso preference set via the wpcom profile settings.
|
||||
* Only used on Simple sites where get_user_attribute is available.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function has_unified_chat_opt_in_enabled( $user_id ) {
|
||||
if ( ! function_exists( '\get_user_attribute' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
|
||||
return ! empty( $calypso_prefs['unified_ai_chat'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unified experience preference from wpcom via Jetpack Connection.
|
||||
*
|
||||
* Used on Atomic sites to delegate the decision to wpcom, which has
|
||||
* access to user attributes and can evaluate the rollout logic.
|
||||
*
|
||||
* Calls /agents-manager/state endpoint which is accessible via Jetpack user tokens.
|
||||
*
|
||||
* @return bool Whether user should see unified experience.
|
||||
*/
|
||||
private function fetch_unified_experience_preference() {
|
||||
$user_id = get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check transient cache first (per-user cache).
|
||||
$cache_key = 'unified-experience-' . $user_id;
|
||||
$cached_result = get_transient( $cache_key );
|
||||
if ( false !== $cached_result ) {
|
||||
return (bool) $cached_result;
|
||||
}
|
||||
|
||||
// Check if user is connected before making API call.
|
||||
if ( ! ( new Connection_Manager() )->is_user_connected( $user_id ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call dedicated agents-manager/state endpoint.
|
||||
$wpcom_request = \Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_user(
|
||||
'/agents-manager/state?key=unified_ai_chat',
|
||||
'2',
|
||||
array( 'method' => 'GET' )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $wpcom_request ) ) {
|
||||
// Cache failures too to avoid hammering the API.
|
||||
set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
|
||||
return false;
|
||||
}
|
||||
|
||||
$response_code = wp_remote_retrieve_response_code( $wpcom_request );
|
||||
if ( 200 !== $response_code ) {
|
||||
set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $wpcom_request );
|
||||
$decoded_body = json_decode( $body, true );
|
||||
|
||||
// The response is { "unified_ai_chat": true/false } when using key param.
|
||||
$result = is_array( $decoded_body ) && ! empty( $decoded_body['unified_ai_chat'] );
|
||||
|
||||
// Cache for 1 minute.
|
||||
set_transient( $cache_key, $result ? 1 : 0, MINUTE_IN_SECONDS );
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current request is on the frontend and the user can edit posts.
|
||||
*
|
||||
* Mirrors Help_Center::is_loading_on_frontend().
|
||||
*
|
||||
* @return bool True if loading on the frontend for an eligible user.
|
||||
*/
|
||||
private static function is_loading_on_frontend() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
|
||||
if ( isset( $_GET['na_site_preview'] ) || isset( $_GET['preview_overlay'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a context check, not a form submission.
|
||||
if ( isset( $_GET['preview'] ) && 'true' === sanitize_text_field( wp_unslash( $_GET['preview'] ) ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$can_edit_posts = current_user_can( 'edit_posts' ) && is_user_member_of_blog();
|
||||
return ! is_admin() && ! self::is_block_editor() && $can_edit_posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current screen is the block editor.
|
||||
*
|
||||
* @return bool True if the current screen is the block editor.
|
||||
*/
|
||||
private static function is_block_editor() {
|
||||
if ( ! function_exists( 'get_current_screen' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current_screen = get_current_screen();
|
||||
// The widgets screen has the block editor but no Gutenberg top bar.
|
||||
return $current_screen && $current_screen->is_block_editor() && $current_screen->id !== 'widgets';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when Gutenberg's "admin bar in editor" (omnibar) experiment is active.
|
||||
*
|
||||
* Mirrors Gutenberg core's gate in `lib/experimental/admin-bar-in-editor/load.php`, and fails
|
||||
* safe when `gutenberg_is_experiment_enabled()` is unavailable.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_admin_bar_in_editor() {
|
||||
return self::is_block_editor()
|
||||
&& is_admin_bar_showing()
|
||||
&& function_exists( 'gutenberg_is_experiment_enabled' )
|
||||
// @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above.
|
||||
&& \gutenberg_is_experiment_enabled( 'gutenberg-admin-bar-in-editor' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current environment is CIAB (Commerce in a Box) / Next Admin.
|
||||
*
|
||||
* Uses the same detection method as Help Center: checks if next_admin_init has fired.
|
||||
*
|
||||
* @return bool True if CIAB/Next Admin environment.
|
||||
*/
|
||||
private static function is_ciab_environment() {
|
||||
return (bool) did_action( 'next_admin_init' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current user is NOT connected through Jetpack.
|
||||
*
|
||||
* Mirrors the logic from Help_Center::is_jetpack_disconnected().
|
||||
*
|
||||
* @return bool True if the site uses Jetpack but the current user is not connected.
|
||||
*/
|
||||
private static function is_jetpack_disconnected() {
|
||||
$user_id = get_current_user_id();
|
||||
$blog_id = get_current_blog_id();
|
||||
|
||||
if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
|
||||
return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
|
||||
}
|
||||
|
||||
if ( true === apply_filters( 'is_jetpack_site', false, $blog_id ) ) {
|
||||
return ! ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user data for the agents manager.
|
||||
*
|
||||
* Mirrors the user data structure from Help Center's helpCenterData.
|
||||
*
|
||||
* @return array|null User data array or null if not logged in.
|
||||
*/
|
||||
private function get_current_user_data() {
|
||||
$user_id = get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user_data = get_userdata( $user_id );
|
||||
if ( ! $user_data ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user_email = $user_data->user_email;
|
||||
|
||||
// Use wpcom_get_avatar_url on Simple sites, fall back to get_avatar_url elsewhere.
|
||||
if ( function_exists( 'wpcom_get_avatar_url' ) ) {
|
||||
$avatar_url = wpcom_get_avatar_url( $user_email, 64, '', true )[0];
|
||||
} else {
|
||||
$avatar_url = get_avatar_url( $user_id );
|
||||
}
|
||||
|
||||
return array(
|
||||
'ID' => $user_id,
|
||||
'username' => $user_data->user_login,
|
||||
'display_name' => $user_data->display_name,
|
||||
'avatar_URL' => $avatar_url,
|
||||
'email' => $user_email,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current site data for the agents manager.
|
||||
*
|
||||
* Returns minimal site data needed by AgentsManager (ID and domain only).
|
||||
* Uses jetpack_options['id'] on Atomic sites for the wpcom blog ID.
|
||||
*
|
||||
* @return array Site data with ID and domain.
|
||||
*/
|
||||
private function get_current_site() {
|
||||
/*
|
||||
* Atomic sites have the WP.com blog ID stored as a Jetpack option.
|
||||
* This code deliberately doesn't use `Jetpack_Options::get_option`
|
||||
* so it works even when Jetpack has not been loaded.
|
||||
*/
|
||||
$jetpack_options = get_option( 'jetpack_options' );
|
||||
if ( is_array( $jetpack_options ) && isset( $jetpack_options['id'] ) ) {
|
||||
$site_id = (int) $jetpack_options['id'];
|
||||
} else {
|
||||
$site_id = get_current_blog_id();
|
||||
}
|
||||
|
||||
return array(
|
||||
'ID' => $site_id,
|
||||
'domain' => wp_parse_url( home_url(), PHP_URL_HOST ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
/**
|
||||
* Open_State_Store file.
|
||||
*
|
||||
* @package automattic/jetpack-agents-manager
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Agents_Manager;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
|
||||
/**
|
||||
* Reads and writes the Agents Manager open state.
|
||||
*
|
||||
* The state is a global, per-user wpcom preference behind the
|
||||
* `/agents-manager/state` endpoint. How the server reads it depends on the site:
|
||||
*
|
||||
* - wpcom Simple: the preference is local, so read `calypso_preferences` directly.
|
||||
* - WoA / self-hosted: the preference is remote, so reads/writes go through this
|
||||
* store's local REST route, which calls wpcom over the Jetpack Connection and
|
||||
* caches the result in a per-user transient. Latency-sensitive readers (the
|
||||
* server-side pre-render) use that transient to skip the round-trip.
|
||||
*/
|
||||
class Open_State_Store {
|
||||
|
||||
/**
|
||||
* Transient key prefix for the cached per-user open state.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const TRANSIENT_PREFIX = 'agents_manager_open_state_';
|
||||
|
||||
/**
|
||||
* Default state values.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public const DEFAULTS = array(
|
||||
'agents_manager_open' => false,
|
||||
'agents_manager_docked' => false,
|
||||
'agents_manager_minimized' => false,
|
||||
'agents_manager_floating_position' => 'right',
|
||||
'agents_manager_router_history' => null,
|
||||
'agents_manager_last_activity' => null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch the open state from wpcom and refresh the cache.
|
||||
*
|
||||
* @return array|\WP_Error Normalized state, or WP_Error when the request fails.
|
||||
*/
|
||||
public static function fetch() {
|
||||
$body = Client::wpcom_json_api_request_as_user(
|
||||
'/agents-manager/state',
|
||||
'2',
|
||||
array( 'method' => 'GET' )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $body ) ) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$response = json_decode( wp_remote_retrieve_body( $body ), true );
|
||||
$state = self::normalize( is_array( $response ) ? $response : array() );
|
||||
|
||||
self::cache( $state );
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the open state to wpcom and refresh the cache.
|
||||
*
|
||||
* @param array $state Partial state to update (subset of DEFAULTS keys).
|
||||
* @return array|\WP_Error Normalized state, or WP_Error when the request fails.
|
||||
*/
|
||||
public static function update( array $state ) {
|
||||
$body = Client::wpcom_json_api_request_as_user(
|
||||
'/agents-manager/state',
|
||||
'2',
|
||||
array( 'method' => 'POST' ),
|
||||
array( 'state' => $state )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $body ) ) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$response = json_decode( wp_remote_retrieve_body( $body ), true );
|
||||
|
||||
if ( ! is_array( $response ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_response',
|
||||
'Invalid response from WPCOM endpoint',
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
$normalized = self::normalize( $response );
|
||||
|
||||
self::cache( $normalized );
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current user's open state from the fastest local source.
|
||||
*
|
||||
* For latency-sensitive callers like the server-side pre-render: Simple sites
|
||||
* read `calypso_preferences` directly, everywhere else uses the cached
|
||||
* transient (see the class docblock). Returns null when nothing is known yet,
|
||||
* so callers can skip pre-rendering until the frontend sets the real state.
|
||||
*
|
||||
* @return array|null `{ agents_manager_open, agents_manager_docked }` or null.
|
||||
*/
|
||||
public static function get_cached() {
|
||||
$user_id = get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Simple sites have the preference locally, so read it directly (the
|
||||
// transient is never primed there).
|
||||
if ( ( new Host() )->is_wpcom_simple() && function_exists( '\get_user_attribute' ) ) {
|
||||
$calypso_prefs = \get_user_attribute( $user_id, 'calypso_preferences' );
|
||||
if ( ! is_array( $calypso_prefs ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array(
|
||||
'agents_manager_open' => (bool) ( $calypso_prefs['agents_manager_open'] ?? false ),
|
||||
'agents_manager_docked' => (bool) ( $calypso_prefs['agents_manager_docked'] ?? false ),
|
||||
);
|
||||
}
|
||||
|
||||
$cached = get_transient( self::cache_key( $user_id ) );
|
||||
|
||||
return is_array( $cached ) ? $cached : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw endpoint response into the full state shape.
|
||||
*
|
||||
* @param array $response Raw decoded response.
|
||||
* @return array Normalized state with all DEFAULTS keys present.
|
||||
*/
|
||||
private static function normalize( array $response ): array {
|
||||
return array(
|
||||
'agents_manager_open' => (bool) ( $response['agents_manager_open'] ?? self::DEFAULTS['agents_manager_open'] ),
|
||||
'agents_manager_docked' => (bool) ( $response['agents_manager_docked'] ?? self::DEFAULTS['agents_manager_docked'] ),
|
||||
'agents_manager_minimized' => (bool) ( $response['agents_manager_minimized'] ?? self::DEFAULTS['agents_manager_minimized'] ),
|
||||
'agents_manager_floating_position' => $response['agents_manager_floating_position'] ?? self::DEFAULTS['agents_manager_floating_position'],
|
||||
'agents_manager_router_history' => $response['agents_manager_router_history'] ?? self::DEFAULTS['agents_manager_router_history'],
|
||||
'agents_manager_last_activity' => $response['agents_manager_last_activity'] ?? self::DEFAULTS['agents_manager_last_activity'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the open/docked bits in a per-user transient.
|
||||
*
|
||||
* Only used on the remote (WoA / self-hosted) path — it's what get_cached()
|
||||
* reads there. Simple sites read `calypso_preferences` directly and skip this.
|
||||
*
|
||||
* @param array $state Normalized state.
|
||||
*/
|
||||
private static function cache( array $state ): void {
|
||||
$user_id = get_current_user_id();
|
||||
if ( ! $user_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter how long the cached open state lives.
|
||||
*
|
||||
* It's refreshed on every read/write through this store, so the TTL mainly
|
||||
* caps how long a value changed elsewhere (e.g. in Calypso) stays stale.
|
||||
*
|
||||
* @since 0.4.0
|
||||
*
|
||||
* @param int $ttl Cache lifetime in seconds.
|
||||
*/
|
||||
$ttl = (int) apply_filters( 'agents_manager_open_state_cache_ttl', WEEK_IN_SECONDS );
|
||||
|
||||
set_transient(
|
||||
self::cache_key( $user_id ),
|
||||
array(
|
||||
'agents_manager_open' => (bool) ( $state['agents_manager_open'] ?? false ),
|
||||
'agents_manager_docked' => (bool) ( $state['agents_manager_docked'] ?? false ),
|
||||
),
|
||||
$ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-user transient key.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return string
|
||||
*/
|
||||
private static function cache_key( int $user_id ): string {
|
||||
return self::TRANSIENT_PREFIX . $user_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* Sidebar Open Preservation file.
|
||||
*
|
||||
* @package automattic/jetpack-agents-manager
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Agents_Manager;
|
||||
|
||||
/**
|
||||
* Preserves the Agents Manager sidebar-open body classes across full wp-admin
|
||||
* navigations so the next page load can pre-apply them server-side (avoiding a
|
||||
* flicker before the React app boots).
|
||||
*
|
||||
* The open state comes from Open_State_Store::get_cached(), and the pre-render only
|
||||
* runs when the Agents Manager app is actually loading on this request — so the
|
||||
* pre-rendered shell is always reconciled by the app that mounts to manage it,
|
||||
* never left orphaned.
|
||||
*
|
||||
* The server can know the persisted "open && docked" preference, but not the
|
||||
* live viewport — and the docked layout only fits above a width breakpoint and
|
||||
* when the admin menu fits vertically. Width is handled in CSS (a static media
|
||||
* query). Height cannot be: the threshold is the *measured* #adminmenu height,
|
||||
* which varies per page. So a tiny synchronous viewport-height gate is printed on
|
||||
* `in_admin_header` (which fires after #adminmenu is in the DOM, before the
|
||||
* content paints) to re-evaluate the real dock gate and strip the pre-rendered
|
||||
* classes when the chat will actually float — pre-paint, so there is no flash.
|
||||
*/
|
||||
class Sidebar_Open_Preservation {
|
||||
/**
|
||||
* Class instance.
|
||||
*
|
||||
* @var Sidebar_Open_Preservation
|
||||
*/
|
||||
private static $instance;
|
||||
|
||||
/**
|
||||
* Body class marking the docked sidebar shell.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const SIDEBAR_CONTAINER_CLASS = 'agents-manager-sidebar-container';
|
||||
|
||||
/**
|
||||
* Body class marking the sidebar as open.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const SIDEBAR_OPEN_CLASS = 'agents-manager-sidebar-container--sidebar-open';
|
||||
|
||||
/**
|
||||
* Creates instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$instance === null ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar_Open_Preservation constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
// Run last so our class sits at the end of the `admin_body_class` list. Otherwise
|
||||
// a later filter could append its class without a leading space and glue it onto
|
||||
// ours, breaking the CSS selector that pre-opens the sidebar.
|
||||
add_filter( 'admin_body_class', array( $this, 'add_preopen_body_classes' ), PHP_INT_MAX );
|
||||
|
||||
// Reconcile the pre-rendered shell against the live viewport before the
|
||||
// page content paints. `in_admin_header` fires after #adminmenu is in the
|
||||
// DOM, so the script can measure it the same way the React app does.
|
||||
add_action( 'in_admin_header', array( $this, 'print_sidebar_docking_gate_script' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject pre-open assistant classes in initial admin body markup.
|
||||
*
|
||||
* @param string $classes Existing admin body classes.
|
||||
* @return string
|
||||
*/
|
||||
public function add_preopen_body_classes( string $classes ): string {
|
||||
if ( ! $this->should_pre_render_docked_shell() ) {
|
||||
return $classes;
|
||||
}
|
||||
|
||||
$body_classes_with_sidebar_classes = implode(
|
||||
' ',
|
||||
array_filter(
|
||||
array(
|
||||
$classes,
|
||||
self::SIDEBAR_CONTAINER_CLASS,
|
||||
self::SIDEBAR_OPEN_CLASS,
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return ' ' . $body_classes_with_sidebar_classes . ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the synchronous sidebar-docking reconciliation script.
|
||||
*
|
||||
* Only emitted when the docked shell was pre-rendered. Because we optimistically
|
||||
* inject the docked sidebar body classes, this script reconciles the gates that
|
||||
* the React hook applies and removes those classes so the chat floats instead.
|
||||
*
|
||||
* The script lives in src/js/sidebar-docking-gate.js and is inlined (not
|
||||
* referenced via `src`) on purpose: it must run render-blocking before paint,
|
||||
* and a same- or cross-origin fetch would add latency to that blocking window.
|
||||
* Reading the bundled file and printing it inline keeps it a real, lintable JS
|
||||
* file with zero request cost.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function print_sidebar_docking_gate_script() {
|
||||
if ( ! $this->should_pre_render_docked_shell() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wp_filesystem;
|
||||
|
||||
if ( empty( $wp_filesystem ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
WP_Filesystem();
|
||||
}
|
||||
|
||||
$script_path = __DIR__ . '/../build/sidebar-docking-gate.js';
|
||||
|
||||
if ( empty( $wp_filesystem ) || ! $wp_filesystem->exists( $script_path ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$script = $wp_filesystem->get_contents( $script_path );
|
||||
if ( ! is_string( $script ) || '' === $script ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_print_inline_script_tag( $script );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the docked-open shell should be pre-rendered on this request.
|
||||
*
|
||||
* True only when the app is loading (so the shell will be reconciled by the
|
||||
* app that mounts to manage it) and the cached state is both open and docked
|
||||
* — the only state that reshapes the admin layout. A cold session (no cache),
|
||||
* a closed sidebar, or a floating (undocked) chat all pre-render nothing.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function should_pre_render_docked_shell(): bool {
|
||||
if ( ! $this->should_preserve_sidebar_open_state() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$state = Open_State_Store::get_cached();
|
||||
|
||||
return $state && true === $state['agents_manager_open'] && true === $state['agents_manager_docked'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether sidebar open preservation should run for this request.
|
||||
*
|
||||
* Gated on the same decision that loads the app (its active variant), so the
|
||||
* pre-rendered shell only appears where the app will mount to reconcile it.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function should_preserve_sidebar_open_state(): bool {
|
||||
return null !== Agents_Manager::get_active_variant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_REST_Agents_Manager_Persisted_Open_State file.
|
||||
*
|
||||
* @package automattic/jetpack-agents-manager
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Agents_Manager;
|
||||
|
||||
/**
|
||||
* Class WP_REST_Agents_Manager_Persisted_Open_State.
|
||||
*/
|
||||
class WP_REST_Agents_Manager_Persisted_Open_State extends \WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* WP_REST_Agents_Manager_Persisted_Open_State constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->namespace = 'agents-manager';
|
||||
$this->rest_base = '/open-state';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register available routes.
|
||||
*/
|
||||
public function register_rest_route() {
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base,
|
||||
array(
|
||||
// Get the open state.
|
||||
array(
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_state' ),
|
||||
'permission_callback' => 'is_user_logged_in',
|
||||
),
|
||||
// Set the open state
|
||||
array(
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => array( $this, 'set_state' ),
|
||||
'permission_callback' => 'is_user_logged_in',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Agents Manager state from user preferences.
|
||||
*
|
||||
* Delegates to Open_State_Store, which fetches the dedicated
|
||||
* wpcom/v2/agents-manager/state endpoint and refreshes the local cache.
|
||||
*/
|
||||
public function get_state() {
|
||||
$state = Open_State_Store::fetch();
|
||||
|
||||
if ( is_wp_error( $state ) ) {
|
||||
return $state;
|
||||
}
|
||||
|
||||
return rest_ensure_response( $state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Agents Manager state in user preferences.
|
||||
*
|
||||
* Delegates to Open_State_Store, which updates the dedicated
|
||||
* wpcom/v2/agents-manager/state endpoint and refreshes the local cache.
|
||||
*
|
||||
* @param \WP_REST_Request $request The request sent to the API.
|
||||
*/
|
||||
public function set_state( \WP_REST_Request $request ) {
|
||||
$state = array();
|
||||
|
||||
foreach ( array_keys( Open_State_Store::DEFAULTS ) as $key ) {
|
||||
if ( $request->has_param( $key ) ) {
|
||||
$state[ $key ] = $request[ $key ];
|
||||
}
|
||||
}
|
||||
|
||||
$result = Open_State_Store::update( $state );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return rest_ensure_response( $result );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* WP_REST_Jetpack_AI_JWT file.
|
||||
*
|
||||
* @package automattic/jetpack-agents-manager
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Agents_Manager;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Jetpack_Options;
|
||||
|
||||
/**
|
||||
* Class WP_REST_Jetpack_AI_JWT.
|
||||
*/
|
||||
class WP_REST_Jetpack_AI_JWT extends \WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* WP_REST_Jetpack_AI_JWT constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->namespace = 'jetpack/v4';
|
||||
$this->rest_base = '/jetpack-ai-jwt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register available routes.
|
||||
*/
|
||||
public function register_rest_route() {
|
||||
/*
|
||||
* Check if the `jetpack/v4/jetpack-ai-jwt` endpoint is registered
|
||||
* by the Jetpack plugin to avoid registering it again.
|
||||
* In case it's not registered, register it
|
||||
* to make it available for Jetpack products that depend on it.
|
||||
*/
|
||||
if ( $this->is_rest_endpoint_registered() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base,
|
||||
array(
|
||||
array(
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => array( $this, 'get_jwt' ),
|
||||
'permission_callback' => array( $this, 'permission_callback' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this REST endpoint is already registered.
|
||||
*
|
||||
* @return bool True if the endpoint is registered, false otherwise.
|
||||
*/
|
||||
private function is_rest_endpoint_registered() {
|
||||
$server = rest_get_server();
|
||||
$routes = $server->get_routes();
|
||||
$full_endpoint = '/' . trim( $this->namespace, '/' ) . $this->rest_base;
|
||||
|
||||
return isset( $routes[ $full_endpoint ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback for the JWT endpoint.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function permission_callback() {
|
||||
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected() && current_user_can( 'edit_posts' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask WPCOM for a JWT token to use for OpenAI completion.
|
||||
*/
|
||||
public function get_jwt() {
|
||||
$blog_id = Jetpack_Options::get_option( 'id' );
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
"/sites/$blog_id/jetpack-openai-query/jwt",
|
||||
'2',
|
||||
array(
|
||||
'method' => 'POST',
|
||||
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
|
||||
),
|
||||
wp_json_encode( array(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$json = json_decode( wp_remote_retrieve_body( $response ) );
|
||||
|
||||
if ( ! isset( $json->token ) ) {
|
||||
return new \WP_Error(
|
||||
'no-token',
|
||||
'No token returned from WPCOM',
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'token' => $json->token,
|
||||
'blog_id' => $blog_id,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Agents Manager pre-paint sidebar-docking gate.
|
||||
*
|
||||
* The assistant floats instead of being docked in certain conditions. Because we
|
||||
* optimistically inject the sidebar classes, we remove them when those conditions
|
||||
* are met.
|
||||
*
|
||||
* IMPORTANT: Keep this logic in sync with
|
||||
* `calypso/packages/agents-manager/src/hooks/use-agent-layout-manager/index.tsx`.
|
||||
*/
|
||||
|
||||
const FULLSCREEN_GATED_BODY_CLASSES = [ 'post-php', 'post-new-php', 'site-editor-php' ];
|
||||
const FULLSCREEN_BODY_CLASS = 'is-fullscreen-mode';
|
||||
|
||||
const DOCKED_SIDEBAR_BODY_CLASSES = [
|
||||
'agents-manager-sidebar-container',
|
||||
'agents-manager-sidebar-container--sidebar-open',
|
||||
];
|
||||
|
||||
// The Calypso hook adds this element when it mounts; its presence is our hand-off signal.
|
||||
const CHAT_PORTAL_CLASS = 'agents-manager-chat';
|
||||
|
||||
// Minimum viewport width for docking, mirroring the hook's `desktopMediaQuery`
|
||||
// default of `(min-width: 1200px)`.
|
||||
const DESKTOP_MIN_WIDTH = 1200;
|
||||
|
||||
/**
|
||||
* Whether the fullscreen gate is open.
|
||||
*
|
||||
* @return {boolean} Whether the fullscreen gate is open.
|
||||
*/
|
||||
function isFullscreenGateOpen() {
|
||||
const { classList } = document.body;
|
||||
const isGated = FULLSCREEN_GATED_BODY_CLASSES.some( cls => classList.contains( cls ) );
|
||||
return ! isGated || classList.contains( FULLSCREEN_BODY_CLASS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the docked shell is currently on the body.
|
||||
*
|
||||
* @return {boolean} Whether the docked shell is present.
|
||||
*/
|
||||
function isDockedShellPresent() {
|
||||
return DOCKED_SIDEBAR_BODY_CLASSES.some( cls => document.body.classList.contains( cls ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the docked shell so the assistant floats.
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
function removeDockedShell() {
|
||||
document.body.classList.remove( ...DOCKED_SIDEBAR_BODY_CLASSES );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the sidebar docking gate evaluation.
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
function runSidebarDockingGate() {
|
||||
if ( ! document.body ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminMenu = document.getElementById( 'adminmenu' );
|
||||
if ( ! adminMenu ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The docked layout pins the admin menu to the viewport; if the menu is taller
|
||||
// than the room below the admin bar it would be clipped, so the chat floats instead.
|
||||
const adminBar = document.getElementById( 'wpadminbar' );
|
||||
const adminBarHeight = adminBar ? adminBar.offsetHeight : 32;
|
||||
const tooShort = window.innerHeight < adminMenu.offsetHeight + adminBarHeight + 20;
|
||||
const tooNarrow = window.innerWidth < DESKTOP_MIN_WIDTH;
|
||||
|
||||
if ( tooShort || tooNarrow || ! isFullscreenGateOpen() ) {
|
||||
removeDockedShell();
|
||||
}
|
||||
}
|
||||
|
||||
runSidebarDockingGate();
|
||||
|
||||
// The gated screen classes (`post-php`, …) and `is-fullscreen-mode` can land on
|
||||
// <body> after the first run, leaving a stale shell on a non-fullscreen editor. So
|
||||
// re-check the fullscreen gate on class changes (cheap reads, no layout) and float
|
||||
// the chat if it closed. Disconnect once the shell is gone (which also stops a
|
||||
// re-trigger loop) or the app mounts and takes over docking.
|
||||
if ( document.body ) {
|
||||
const observer = new MutationObserver( () => {
|
||||
if ( ! isDockedShellPresent() || document.querySelector( `.${ CHAT_PORTAL_CLASS }` ) ) {
|
||||
observer.disconnect();
|
||||
return;
|
||||
}
|
||||
if ( ! isFullscreenGateOpen() ) {
|
||||
removeDockedShell();
|
||||
}
|
||||
} );
|
||||
observer.observe( document.body, { attributes: true, attributeFilter: [ 'class' ] } );
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
/**
|
||||
* Action Hooks for Jetpack Assets module.
|
||||
*
|
||||
* @package automattic/jetpack-assets
|
||||
*/
|
||||
|
||||
// If WordPress's plugin API is available already, use it. If not,
|
||||
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action( 'wp_default_scripts', array( Automattic\Jetpack\Assets::class, 'wp_default_scripts_hook' ) );
|
||||
add_action( 'plugins_loaded', array( Automattic\Jetpack\Assets\Script_Data::class, 'configure' ), 1 );
|
||||
add_action( 'plugins_loaded', array( Automattic\Jetpack\Assets\Shared_Stores_Assets::class, 'configure' ), 1 );
|
||||
} else {
|
||||
global $wp_filter;
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_filter['wp_default_scripts'][10][] = array(
|
||||
'accepted_args' => 1,
|
||||
'function' => array( Automattic\Jetpack\Assets::class, 'wp_default_scripts_hook' ),
|
||||
);
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_filter['plugins_loaded'][1][] = array(
|
||||
'accepted_args' => 0,
|
||||
'function' => array( Automattic\Jetpack\Assets\Script_Data::class, 'configure' ),
|
||||
);
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_filter['plugins_loaded'][1][] = array(
|
||||
'accepted_args' => 0,
|
||||
'function' => array( Automattic\Jetpack\Assets\Shared_Stores_Assets::class, 'configure' ),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('wp-i18n'), 'version' => '5ba5dddc04be2306aaf0');
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array(), 'version' => '063e99e8f3d8166cfb17');
|
||||
@@ -0,0 +1 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.JetpackScriptDataModule=t():e.JetpackScriptDataModule=t()}(globalThis,()=>(()=>{"use strict";var e={729(e,t,r){r.d(t,{$8:()=>n.$8,IT:()=>n.IT,KS:()=>n.KS,L2:()=>n.L2,Sy:()=>n.Sy,au:()=>n.au,d_:()=>n.d_,e5:()=>n.e5,hT:()=>n.hT,lI:()=>n.lI,mH:()=>n.mH,oQ:()=>n.oQ,sV:()=>n.sV});var n=r(336)},336(e,t,r){function n(){return window.JetpackScriptData}function o(){return n()?.site}function i(e=""){return`${n()?.site.admin_url}${e}`}function a(e=""){return i(`admin.php?page=jetpack${e}`)}function u(e=""){return i(`admin.php?page=my-jetpack${e}`)}function p(){return n()?.site.plan?.features?.active??[]}function c(e){return-1!==p().indexOf(e)}function s(){return"wpcom"===n()?.site?.host}function f(){return"woa"===n()?.site?.host}function d(){return n()?.site?.is_wpcom_platform}function l(){return"unknown"===n()?.site?.host}function S(){return s()?"simple":f()?"woa":"jetpack"}function m(e){return n()?.user.current_user.capabilities[e]}r.d(t,{$8:()=>d,IT:()=>c,KS:()=>S,L2:()=>l,Sy:()=>s,au:()=>n,d_:()=>m,e5:()=>u,hT:()=>i,lI:()=>f,mH:()=>p,oQ:()=>a,sV:()=>o})}},t={};function r(n){var o=t[n];if(void 0!==o)return o.exports;var i=t[n]={exports:{}};return e[n](i,i.exports,r),i.exports}r.d=(e,t)=>{for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var n={};r.r(n),r.d(n,{currentUserCan:()=>o.d_,getActiveFeatures:()=>o.mH,getAdminUrl:()=>o.hT,getJetpackAdminPageUrl:()=>o.oQ,getMyJetpackUrl:()=>o.e5,getScriptData:()=>o.au,getSiteData:()=>o.sV,getSiteType:()=>o.KS,isJetpackSelfHostedSite:()=>o.L2,isSimpleSite:()=>o.Sy,isWoASite:()=>o.lI,isWpcomPlatformSite:()=>o.$8,siteHasFeature:()=>o.IT});var o=r(729);return n})());
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('jetpack-script-data', 'wp-api-fetch', 'wp-data', 'wp-polyfill'), 'version' => '09c0b5848292ccbc06d4');
|
||||
@@ -0,0 +1,763 @@
|
||||
<?php
|
||||
/**
|
||||
* Jetpack Assets package.
|
||||
*
|
||||
* @package automattic/jetpack-assets
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack;
|
||||
|
||||
use Automattic\Jetpack\Assets\Semver;
|
||||
use Automattic\Jetpack\Constants as Jetpack_Constants;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Class Assets
|
||||
*/
|
||||
class Assets {
|
||||
/**
|
||||
* Holds all the scripts handles that should be loaded in a deferred fashion.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $defer_script_handles = array();
|
||||
|
||||
/**
|
||||
* The singleton instance of this class.
|
||||
*
|
||||
* @var Assets
|
||||
*/
|
||||
protected static $instance;
|
||||
|
||||
/**
|
||||
* The registered textdomain mappings.
|
||||
*
|
||||
* @var array `array( mapped_domain => array( string target_domain, string target_type, string semver, string path_prefix ) )`.
|
||||
*/
|
||||
private static $domain_map = array();
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* Static-only class, so nothing here.
|
||||
*/
|
||||
private function __construct() {}
|
||||
|
||||
// ////////////////////
|
||||
// region Async script loading
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the class.
|
||||
*
|
||||
* @return Assets
|
||||
*/
|
||||
public static function instance() {
|
||||
if ( ! isset( self::$instance ) ) {
|
||||
self::$instance = new Assets();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* A public method for adding the async script.
|
||||
*
|
||||
* @deprecated Since 2.1.0, the `strategy` feature should be used instead, with the "defer" setting.
|
||||
*
|
||||
* @param string $script_handle Script handle.
|
||||
*/
|
||||
public static function add_async_script( $script_handle ) {
|
||||
_deprecated_function( __METHOD__, '2.1.0' );
|
||||
|
||||
wp_script_add_data( $script_handle, 'strategy', 'defer' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an async attribute to scripts that can be loaded deferred.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script
|
||||
*
|
||||
* @deprecated Since 2.1.0, the `strategy` feature should be used instead.
|
||||
*
|
||||
* @param string $tag The <script> tag for the enqueued script.
|
||||
* @param string $handle The script's registered handle.
|
||||
*/
|
||||
public function script_add_async( $tag, $handle ) {
|
||||
_deprecated_function( __METHOD__, '2.1.0' );
|
||||
if ( empty( $this->defer_script_handles ) ) {
|
||||
return $tag;
|
||||
}
|
||||
|
||||
if ( in_array( $handle, $this->defer_script_handles, true ) ) {
|
||||
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
|
||||
return preg_replace( '/<script( [^>]*)? src=/i', '<script defer$1 src=', $tag );
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that lets you enqueue scripts in an async fashion.
|
||||
*
|
||||
* @deprecated Since 2.1.0 - use the strategy feature instead.
|
||||
*
|
||||
* @param string $handle Name of the script. Should be unique.
|
||||
* @param string $min_path Minimized script path.
|
||||
* @param string $non_min_path Full Script path.
|
||||
* @param array $deps Array of script dependencies.
|
||||
* @param bool $ver The script version.
|
||||
* @param bool $in_footer Should the script be included in the footer.
|
||||
*/
|
||||
public static function enqueue_async_script( $handle, $min_path, $non_min_path, $deps = array(), $ver = false, $in_footer = true ) {
|
||||
_deprecated_function( __METHOD__, '2.1.0' );
|
||||
wp_enqueue_script( $handle, self::get_file_url_for_environment( $min_path, $non_min_path ), $deps, $ver, $in_footer );
|
||||
wp_script_add_data( $handle, 'strategy', 'defer' );
|
||||
}
|
||||
|
||||
// endregion .
|
||||
|
||||
// ////////////////////
|
||||
// region Utils
|
||||
|
||||
/**
|
||||
* Given a minified path, and a non-minified path, will return
|
||||
* a minified or non-minified file URL based on whether SCRIPT_DEBUG is set and truthy.
|
||||
*
|
||||
* If $package_path is provided, then the minified or non-minified file URL will be generated
|
||||
* relative to the root package directory.
|
||||
*
|
||||
* Both `$min_base` and `$non_min_base` can be either full URLs, or are expected to be relative to the
|
||||
* root Jetpack directory.
|
||||
*
|
||||
* @param string $min_path minified path.
|
||||
* @param string $non_min_path non-minified path.
|
||||
* @param string $package_path Optional. A full path to a file inside a package directory
|
||||
* The URL will be relative to its directory. Default empty.
|
||||
* Typically this is done by passing __FILE__ as the argument.
|
||||
*
|
||||
* @return string The URL to the file
|
||||
* @since 1.0.3
|
||||
* @since-jetpack 5.6.0
|
||||
*/
|
||||
public static function get_file_url_for_environment( $min_path, $non_min_path, $package_path = '' ) {
|
||||
$path = ( Jetpack_Constants::is_defined( 'SCRIPT_DEBUG' ) && Jetpack_Constants::get_constant( 'SCRIPT_DEBUG' ) )
|
||||
? $non_min_path
|
||||
: $min_path;
|
||||
|
||||
/*
|
||||
* If the path is actually a full URL, keep that.
|
||||
* We look for a host value, since enqueues are sometimes without a scheme.
|
||||
*/
|
||||
$file_parts = wp_parse_url( $path );
|
||||
if ( ! empty( $file_parts['host'] ) ) {
|
||||
$url = $path;
|
||||
} else {
|
||||
$plugin_path = empty( $package_path ) ? Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) : $package_path;
|
||||
|
||||
$url = plugins_url( $path, $plugin_path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the URL for a file passed through the get_file_url_for_environment function.
|
||||
*
|
||||
* @since 1.0.3
|
||||
*
|
||||
* @package assets
|
||||
*
|
||||
* @param string $url The URL to the file.
|
||||
* @param string $min_path The minified path.
|
||||
* @param string $non_min_path The non-minified path.
|
||||
*/
|
||||
return apply_filters( 'jetpack_get_file_for_environment', $url, $min_path, $non_min_path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Passes an array of URLs to wp_resource_hints.
|
||||
*
|
||||
* @since 1.5.0
|
||||
*
|
||||
* @param string|array $urls URLs to hint.
|
||||
* @param string $type One of the supported resource types: dns-prefetch (default), preconnect, prefetch, or prerender.
|
||||
*/
|
||||
public static function add_resource_hint( $urls, $type = 'dns-prefetch' ) {
|
||||
add_filter(
|
||||
'wp_resource_hints',
|
||||
function ( $hints, $resource_type ) use ( $urls, $type ) {
|
||||
if ( $resource_type === $type ) {
|
||||
// Type casting to array required since the function accepts a single string.
|
||||
foreach ( (array) $urls as $url ) {
|
||||
$hints[] = $url;
|
||||
}
|
||||
}
|
||||
return $hints;
|
||||
},
|
||||
10,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a WordPress.com static resource via a randomized wp.com subdomain.
|
||||
*
|
||||
* @since 1.9.0
|
||||
*
|
||||
* @param string $url WordPress.com static resource URL.
|
||||
*
|
||||
* @return string $url
|
||||
*/
|
||||
public static function staticize_subdomain( $url ) {
|
||||
// Extract hostname from URL.
|
||||
$host = wp_parse_url( $url, PHP_URL_HOST );
|
||||
|
||||
// Explode hostname on '.'.
|
||||
$exploded_host = explode( '.', $host );
|
||||
|
||||
// Retrieve the name and TLD.
|
||||
if ( count( $exploded_host ) > 1 ) {
|
||||
$name = $exploded_host[ count( $exploded_host ) - 2 ];
|
||||
$tld = $exploded_host[ count( $exploded_host ) - 1 ];
|
||||
// Rebuild domain excluding subdomains.
|
||||
$domain = $name . '.' . $tld;
|
||||
} else {
|
||||
$domain = $host;
|
||||
}
|
||||
// Array of Automattic domains.
|
||||
$domains_allowed = array( 'wordpress.com', 'wp.com' );
|
||||
|
||||
// Return $url if not an Automattic domain.
|
||||
if ( ! in_array( $domain, $domains_allowed, true ) ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
if ( \is_ssl() ) {
|
||||
return preg_replace( '|https?://[^/]++/|', 'https://s-ssl.wordpress.com/', $url );
|
||||
}
|
||||
|
||||
/*
|
||||
* Generate a random subdomain id by taking the modulus of the crc32 value of the URL.
|
||||
* Valid values are 0, 1, and 2.
|
||||
*/
|
||||
$static_counter = abs( crc32( basename( $url ) ) % 3 );
|
||||
|
||||
return preg_replace( '|://[^/]+?/|', "://s$static_counter.wp.com/", $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve '.' and '..' components in a path or URL.
|
||||
*
|
||||
* @since 1.12.0
|
||||
* @param string $path Path or URL.
|
||||
* @return string Normalized path or URL.
|
||||
*/
|
||||
public static function normalize_path( $path ) {
|
||||
$parts = wp_parse_url( $path );
|
||||
if ( ! isset( $parts['path'] ) ) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$ret = '';
|
||||
$ret .= isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
|
||||
if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
|
||||
$ret .= $parts['user'] ?? '';
|
||||
$ret .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : '';
|
||||
$ret .= '@';
|
||||
}
|
||||
$ret .= $parts['host'] ?? '';
|
||||
$ret .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';
|
||||
|
||||
$pp = explode( '/', $parts['path'] );
|
||||
if ( '' === $pp[0] ) {
|
||||
$ret .= '/';
|
||||
array_shift( $pp );
|
||||
}
|
||||
$i = 0;
|
||||
while ( $i < count( $pp ) ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
|
||||
if ( '' === $pp[ $i ] || '.' === $pp[ $i ] || 0 === $i && '..' === $pp[ $i ] ) {
|
||||
array_splice( $pp, $i, 1 );
|
||||
} elseif ( '..' === $pp[ $i ] ) {
|
||||
array_splice( $pp, --$i, 2 );
|
||||
} else {
|
||||
++$i;
|
||||
}
|
||||
}
|
||||
$ret .= implode( '/', $pp );
|
||||
|
||||
$ret .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';
|
||||
$ret .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
// endregion .
|
||||
|
||||
// ////////////////////
|
||||
// region Webpack-built script registration
|
||||
|
||||
/**
|
||||
* Register a Webpack-built script.
|
||||
*
|
||||
* Our Webpack-built scripts tend to need a bunch of boilerplate:
|
||||
* - A call to `Assets::get_file_url_for_environment()` for possible debugging.
|
||||
* - A call to `wp_register_style()` for extracted CSS, possibly with detection of RTL.
|
||||
* - Loading of dependencies and version provided by `@wordpress/dependency-extraction-webpack-plugin`.
|
||||
* - Avoiding WPCom's broken minifier.
|
||||
*
|
||||
* This wrapper handles all of that.
|
||||
*
|
||||
* @since 1.12.0
|
||||
* @since 2.1.0 Add a new `strategy` option to leverage WP >= 6.3 script strategy feature. The `async` option is deprecated.
|
||||
* @param string $handle Name of the script. Should be unique across both scripts and styles.
|
||||
* @param string $path Minimized script path.
|
||||
* @param string $relative_to File that `$path` is relative to. Pass `__FILE__`.
|
||||
* @param array $options Additional options:
|
||||
* - `asset_path`: (string|null) `.asset.php` to load. Default is to base it on `$path`.
|
||||
* - `async`: (bool) Set true to register the script as deferred, like `Assets::enqueue_async_script()`. Deprecated in favor of `strategy`.
|
||||
* - `css_dependencies`: (string[]) Additional style dependencies to queue.
|
||||
* - `css_path`: (string|null) `.css` to load. Default is to base it on `$path`.
|
||||
* - `dependencies`: (string[]) Additional script dependencies to queue.
|
||||
* - `enqueue`: (bool) Set true to enqueue the script immediately.
|
||||
* - `in_footer`: (bool) Set true to register script for the footer.
|
||||
* - `media`: (string) Media for the css file. Default 'all'.
|
||||
* - `minify`: (bool|null) Set true to pass `minify=true` in the query string, or `null` to suppress the normal `minify=false`.
|
||||
* - `nonmin_path`: (string) Non-minified script path.
|
||||
* - `strategy`: (string) Specify a script strategy to use, eg. `defer` or `async`. Default is `""`.
|
||||
* - `textdomain`: (string) Text domain for the script. Required if the script depends on wp-i18n.
|
||||
* - `version`: (string) Override the version from the `asset_path` file.
|
||||
* @phan-param array{asset_path?:?string,async?:bool,css_dependencies?:string[],css_path?:?string,dependencies?:string[],enqueue?:bool,in_footer?:bool,media?:string,minify?:?bool,nonmin_path?:string,strategy?:string,textdomain?:string,version?:string} $options
|
||||
* @throws \InvalidArgumentException If arguments are invalid.
|
||||
*/
|
||||
public static function register_script( $handle, $path, $relative_to, array $options = array() ) {
|
||||
if ( substr( $path, -3 ) !== '.js' ) {
|
||||
throw new \InvalidArgumentException( '$path must end in ".js"' );
|
||||
}
|
||||
|
||||
if ( isset( $options['async'] ) ) {
|
||||
_deprecated_argument( __METHOD__, '2.1.0', 'The `async` option is deprecated in favor of `strategy`' );
|
||||
}
|
||||
|
||||
$dir = dirname( $relative_to );
|
||||
$base = substr( $path, 0, -3 );
|
||||
$options += array(
|
||||
'asset_path' => "$base.asset.php",
|
||||
'async' => false,
|
||||
'css_dependencies' => array(),
|
||||
'css_path' => "$base.css",
|
||||
'dependencies' => array(),
|
||||
'enqueue' => false,
|
||||
'in_footer' => false,
|
||||
'media' => 'all',
|
||||
'minify' => false,
|
||||
'strategy' => '',
|
||||
'textdomain' => null,
|
||||
);
|
||||
'@phan-var array{asset_path:?string,async:bool,css_dependencies:string[],css_path:?string,dependencies:string[],enqueue:bool,in_footer:bool,media:string,minify:?bool,nonmin_path?:string,strategy:string,textdomain:string,version?:string} $options'; // Phan gets confused by the array addition.
|
||||
|
||||
if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && substr( $options['css_path'], -4 ) !== '.css' ) {
|
||||
throw new \InvalidArgumentException( '$options[\'css_path\'] must end in ".css"' );
|
||||
}
|
||||
|
||||
if ( isset( $options['nonmin_path'] ) ) {
|
||||
$url = self::get_file_url_for_environment( $path, $options['nonmin_path'], $relative_to );
|
||||
} else {
|
||||
$url = plugins_url( $path, $relative_to );
|
||||
}
|
||||
$url = self::normalize_path( $url );
|
||||
if ( null !== $options['minify'] ) {
|
||||
$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
|
||||
}
|
||||
|
||||
if ( $options['asset_path'] && file_exists( "$dir/{$options['asset_path']}" ) ) {
|
||||
$asset = require "$dir/{$options['asset_path']}"; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
|
||||
$options['dependencies'] = array_merge( $asset['dependencies'], $options['dependencies'] );
|
||||
$options['css_dependencies'] = array_merge(
|
||||
array_filter(
|
||||
$asset['dependencies'],
|
||||
function ( $d ) {
|
||||
return wp_style_is( $d, 'registered' );
|
||||
}
|
||||
),
|
||||
$options['css_dependencies']
|
||||
);
|
||||
$ver = $options['version'] ?? $asset['version'];
|
||||
} else {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
$ver = $options['version'] ?? @filemtime( "$dir/$path" );
|
||||
}
|
||||
|
||||
if ( $options['async'] && '' === $options['strategy'] ) { // Handle the deprecated `async` option
|
||||
$options['strategy'] = 'defer';
|
||||
}
|
||||
wp_register_script(
|
||||
$handle,
|
||||
$url,
|
||||
$options['dependencies'],
|
||||
$ver,
|
||||
array(
|
||||
'in_footer' => $options['in_footer'],
|
||||
'strategy' => $options['strategy'],
|
||||
)
|
||||
);
|
||||
|
||||
if ( $options['textdomain'] ) {
|
||||
// phpcs:ignore Jetpack.Functions.I18n.DomainNotLiteral
|
||||
wp_set_script_translations( $handle, $options['textdomain'] );
|
||||
} elseif ( in_array( 'wp-i18n', $options['dependencies'], true ) ) {
|
||||
_doing_it_wrong(
|
||||
__METHOD__,
|
||||
/* translators: %s is the script handle. */
|
||||
esc_html( sprintf( __( 'Script "%s" depends on wp-i18n but does not specify "textdomain"', 'jetpack-assets' ), $handle ) ),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && file_exists( "$dir/{$options['css_path']}" ) ) {
|
||||
$csspath = $options['css_path'];
|
||||
if ( is_rtl() ) {
|
||||
$rtlcsspath = substr( $csspath, 0, -4 ) . '.rtl.css';
|
||||
if ( file_exists( "$dir/$rtlcsspath" ) ) {
|
||||
$csspath = $rtlcsspath;
|
||||
}
|
||||
}
|
||||
|
||||
$url = self::normalize_path( plugins_url( $csspath, $relative_to ) );
|
||||
if ( null !== $options['minify'] ) {
|
||||
$url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
|
||||
}
|
||||
wp_register_style( $handle, $url, $options['css_dependencies'], $ver, $options['media'] );
|
||||
wp_script_add_data( $handle, 'Jetpack::Assets::hascss', true );
|
||||
} else {
|
||||
wp_script_add_data( $handle, 'Jetpack::Assets::hascss', false );
|
||||
}
|
||||
|
||||
if ( $options['enqueue'] ) {
|
||||
self::enqueue_script( $handle );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a script registered with `Assets::register_script`.
|
||||
*
|
||||
* @since 1.12.0
|
||||
* @param string $handle Name of the script. Should be unique across both scripts and styles.
|
||||
*/
|
||||
public static function enqueue_script( $handle ) {
|
||||
wp_enqueue_script( $handle );
|
||||
if ( wp_scripts()->get_data( $handle, 'Jetpack::Assets::hascss' ) ) {
|
||||
wp_enqueue_style( $handle );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 'wp_default_scripts' action handler.
|
||||
*
|
||||
* This registers the `wp-jp-i18n-loader` script for use by Webpack bundles built with
|
||||
* `@automattic/i18n-loader-webpack-plugin`.
|
||||
*
|
||||
* @since 1.14.0
|
||||
* @param \WP_Scripts $wp_scripts WP_Scripts instance.
|
||||
*/
|
||||
public static function wp_default_scripts_hook( $wp_scripts ) {
|
||||
$data = array(
|
||||
'baseUrl' => false,
|
||||
'locale' => determine_locale(),
|
||||
'domainMap' => array(),
|
||||
'domainPaths' => array(),
|
||||
);
|
||||
|
||||
$lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
|
||||
$content_dir = Jetpack_Constants::get_constant( 'WP_CONTENT_DIR' );
|
||||
$abspath = Jetpack_Constants::get_constant( 'ABSPATH' );
|
||||
|
||||
// Note: str_starts_with() is not used here, as wp-includes/compat.php may not be loaded at this point.
|
||||
if ( strpos( $lang_dir, $content_dir ) === 0 ) {
|
||||
$data['baseUrl'] = content_url( substr( trailingslashit( $lang_dir ), strlen( trailingslashit( $content_dir ) ) ) );
|
||||
} elseif ( strpos( $lang_dir, $abspath ) === 0 ) {
|
||||
$data['baseUrl'] = site_url( substr( trailingslashit( $lang_dir ), strlen( untrailingslashit( $abspath ) ) ) );
|
||||
}
|
||||
|
||||
foreach ( self::$domain_map as $from => list( $to, $type, , $path ) ) {
|
||||
$data['domainMap'][ $from ] = ( 'core' === $type ? '' : "{$type}/" ) . $to;
|
||||
if ( '' !== $path ) {
|
||||
$data['domainPaths'][ $from ] = trailingslashit( $path );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the i18n state data for use by Webpack bundles built with
|
||||
* `@automattic/i18n-loader-webpack-plugin`.
|
||||
*
|
||||
* @since 1.14.0
|
||||
* @package assets
|
||||
* @param array $data The state data to generate. Expected fields are:
|
||||
* - `baseUrl`: (string|false) The URL to the languages directory. False if no URL could be determined.
|
||||
* - `locale`: (string) The locale for the page.
|
||||
* - `domainMap`: (string[]) A mapping from Composer package textdomains to the corresponding
|
||||
* `plugins/textdomain` or `themes/textdomain` (or core `textdomain`, but that's unlikely).
|
||||
* - `domainPaths`: (string[]) A mapping from Composer package textdomains to the corresponding package
|
||||
* paths.
|
||||
*/
|
||||
$data = apply_filters( 'jetpack_i18n_state', $data );
|
||||
|
||||
// Can't use self::register_script(), this action is called too early.
|
||||
if ( file_exists( __DIR__ . '/../build/i18n-loader.asset.php' ) ) {
|
||||
$path = '../build/i18n-loader.js';
|
||||
$asset = require __DIR__ . '/../build/i18n-loader.asset.php';
|
||||
} else {
|
||||
$path = 'js/i18n-loader.js';
|
||||
$asset = array(
|
||||
'dependencies' => array( 'wp-i18n' ),
|
||||
'version' => filemtime( __DIR__ . "/$path" ),
|
||||
);
|
||||
}
|
||||
$url = self::normalize_path( plugins_url( $path, __FILE__ ) );
|
||||
$url = add_query_arg( 'minify', 'true', $url );
|
||||
|
||||
$handle = 'wp-jp-i18n-loader';
|
||||
|
||||
$wp_scripts->add( $handle, $url, $asset['dependencies'], $asset['version'] );
|
||||
|
||||
// Ensure the script is loaded in the footer and deferred.
|
||||
$wp_scripts->add_data( $handle, 'group', 1 );
|
||||
|
||||
if ( ! is_array( $data ) ||
|
||||
! isset( $data['baseUrl'] ) || ! ( is_string( $data['baseUrl'] ) || false === $data['baseUrl'] ) ||
|
||||
! isset( $data['locale'] ) || ! is_string( $data['locale'] ) ||
|
||||
! isset( $data['domainMap'] ) || ! is_array( $data['domainMap'] ) ||
|
||||
! isset( $data['domainPaths'] ) || ! is_array( $data['domainPaths'] )
|
||||
) {
|
||||
$wp_scripts->add_inline_script( $handle, 'console.warn( "I18n state deleted by jetpack_i18n_state hook" );' );
|
||||
} elseif ( ! $data['baseUrl'] ) {
|
||||
$wp_scripts->add_inline_script( $handle, 'console.warn( "Failed to determine languages base URL. Is WP_LANG_DIR in the WordPress root?" );' );
|
||||
} else {
|
||||
$data['domainMap'] = (object) $data['domainMap']; // Ensure it becomes a json object.
|
||||
$data['domainPaths'] = (object) $data['domainPaths']; // Ensure it becomes a json object.
|
||||
$wp_scripts->add_inline_script( $handle, 'wp.jpI18nLoader.state = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' );
|
||||
}
|
||||
|
||||
// Deprecated state module: Depend on wp-i18n to ensure global `wp` exists and because anything needing this will need that too.
|
||||
$wp_scripts->add( 'wp-jp-i18n-state', false, array( 'wp-deprecated', $handle ) );
|
||||
$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.deprecated( "wp-jp-i18n-state", { alternative: "wp-jp-i18n-loader" } );' );
|
||||
$wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.jpI18nState = wp.jpI18nLoader.state;' );
|
||||
}
|
||||
|
||||
// endregion .
|
||||
|
||||
// ////////////////////
|
||||
// region Textdomain aliasing
|
||||
|
||||
/**
|
||||
* Register a textdomain alias.
|
||||
*
|
||||
* Composer packages included in plugins will likely not use the textdomain of the plugin, while
|
||||
* WordPress's i18n infrastructure will include the translations in the plugin's domain. This
|
||||
* allows for mapping the package's domain to the plugin's.
|
||||
*
|
||||
* Since multiple plugins may use the same package, we include the package's version here so
|
||||
* as to choose the most recent translations (which are most likely to match the package
|
||||
* selected by jetpack-autoloader).
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $from Domain to alias.
|
||||
* @param string $to Domain to alias it to.
|
||||
* @param string $totype What is the target of the alias: 'plugins', 'themes', or 'core'.
|
||||
* @param string $ver Version of the `$from` domain.
|
||||
* @param string $path Path to prepend when lazy-loading from JavaScript.
|
||||
* @throws InvalidArgumentException If arguments are invalid.
|
||||
*/
|
||||
public static function alias_textdomain( $from, $to, $totype, $ver, $path = '' ) {
|
||||
if ( ! in_array( $totype, array( 'plugins', 'themes', 'core' ), true ) ) {
|
||||
throw new InvalidArgumentException( 'Type must be "plugins", "themes", or "core"' );
|
||||
}
|
||||
|
||||
if (
|
||||
did_action( 'wp_default_scripts' ) &&
|
||||
// Don't complain during plugin activation.
|
||||
! defined( 'WP_SANDBOX_SCRAPING' )
|
||||
) {
|
||||
_doing_it_wrong(
|
||||
__METHOD__,
|
||||
sprintf(
|
||||
/* translators: 1: wp_default_scripts. 2: Name of the domain being aliased. */
|
||||
esc_html__( 'Textdomain aliases should be registered before the %1$s hook. This notice was triggered by the %2$s domain.', 'jetpack-assets' ),
|
||||
'<code>wp_default_scripts</code>',
|
||||
'<code>' . esc_html( $from ) . '</code>'
|
||||
),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if ( empty( self::$domain_map[ $from ] ) ) {
|
||||
self::init_domain_map_hooks( $from, array() === self::$domain_map );
|
||||
self::$domain_map[ $from ] = array( $to, $totype, $ver, $path );
|
||||
} elseif ( Semver::compare( $ver, self::$domain_map[ $from ][2] ) > 0 ) {
|
||||
self::$domain_map[ $from ] = array( $to, $totype, $ver, $path );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register textdomain aliases from a mapping file.
|
||||
*
|
||||
* The mapping file is simply a PHP file that returns an array
|
||||
* with the following properties:
|
||||
* - 'domain': String, `$to`
|
||||
* - 'type': String, `$totype`
|
||||
* - 'packages': Array, mapping `$from` to `array( 'path' => $path, 'ver' => $ver )` (or to the string `$ver` for back compat).
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $file Mapping file.
|
||||
*/
|
||||
public static function alias_textdomains_from_file( $file ) {
|
||||
$data = require $file;
|
||||
foreach ( $data['packages'] as $from => $fromdata ) {
|
||||
if ( ! is_array( $fromdata ) ) {
|
||||
$fromdata = array(
|
||||
'path' => '',
|
||||
'ver' => $fromdata,
|
||||
);
|
||||
}
|
||||
self::alias_textdomain( $from, $data['domain'], $data['type'], $fromdata['ver'], $fromdata['path'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the hooks for textdomain aliasing.
|
||||
*
|
||||
* @param string $domain Domain to alias.
|
||||
* @param bool $firstcall If this is the first call.
|
||||
*/
|
||||
private static function init_domain_map_hooks( $domain, $firstcall ) {
|
||||
// If WordPress's plugin API is available already, use it. If not,
|
||||
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
|
||||
if ( function_exists( 'add_filter' ) ) {
|
||||
$add_filter = 'add_filter';
|
||||
} else {
|
||||
$add_filter = function ( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
|
||||
global $wp_filter;
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_filter[ $hook_name ][ $priority ][] = array(
|
||||
'accepted_args' => $accepted_args,
|
||||
'function' => $callback,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
$add_filter( "gettext_{$domain}", array( self::class, 'filter_gettext' ), 10, 3 );
|
||||
$add_filter( "ngettext_{$domain}", array( self::class, 'filter_ngettext' ), 10, 5 );
|
||||
$add_filter( "gettext_with_context_{$domain}", array( self::class, 'filter_gettext_with_context' ), 10, 4 );
|
||||
$add_filter( "ngettext_with_context_{$domain}", array( self::class, 'filter_ngettext_with_context' ), 10, 6 );
|
||||
if ( $firstcall ) {
|
||||
$add_filter( 'load_script_translation_file', array( self::class, 'filter_load_script_translation_file' ), 10, 3 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for `gettext`.
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $translation Translated text.
|
||||
* @param string $text Text to translate.
|
||||
* @param string $domain Text domain.
|
||||
* @return string Translated text.
|
||||
*/
|
||||
public static function filter_gettext( $translation, $text, $domain ) {
|
||||
if ( $translation === $text ) {
|
||||
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
|
||||
$newtext = __( $text, self::$domain_map[ $domain ][0] );
|
||||
if ( $newtext !== $text ) {
|
||||
return $newtext;
|
||||
}
|
||||
}
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for `ngettext`.
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $translation Translated text.
|
||||
* @param string $single The text to be used if the number is singular.
|
||||
* @param string $plural The text to be used if the number is plural.
|
||||
* @param int $number The number to compare against to use either the singular or plural form.
|
||||
* @param string $domain Text domain.
|
||||
* @return string Translated text.
|
||||
*/
|
||||
public static function filter_ngettext( $translation, $single, $plural, $number, $domain ) {
|
||||
if ( $translation === $single || $translation === $plural ) {
|
||||
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
|
||||
$translation = _n( $single, $plural, $number, self::$domain_map[ $domain ][0] );
|
||||
}
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for `gettext_with_context`.
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $translation Translated text.
|
||||
* @param string $text Text to translate.
|
||||
* @param string $context Context information for the translators.
|
||||
* @param string $domain Text domain.
|
||||
* @return string Translated text.
|
||||
*/
|
||||
public static function filter_gettext_with_context( $translation, $text, $context, $domain ) {
|
||||
if ( $translation === $text ) {
|
||||
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
|
||||
$translation = _x( $text, $context, self::$domain_map[ $domain ][0] );
|
||||
}
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for `ngettext_with_context`.
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string $translation Translated text.
|
||||
* @param string $single The text to be used if the number is singular.
|
||||
* @param string $plural The text to be used if the number is plural.
|
||||
* @param int $number The number to compare against to use either the singular or plural form.
|
||||
* @param string $context Context information for the translators.
|
||||
* @param string $domain Text domain.
|
||||
* @return string Translated text.
|
||||
*/
|
||||
public static function filter_ngettext_with_context( $translation, $single, $plural, $number, $context, $domain ) {
|
||||
if ( $translation === $single || $translation === $plural ) {
|
||||
// phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
|
||||
$translation = _nx( $single, $plural, $number, $context, self::$domain_map[ $domain ][0] );
|
||||
}
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for `load_script_translation_file`.
|
||||
*
|
||||
* @since 1.15.0
|
||||
* @param string|false $file Path to the translation file to load. False if there isn't one.
|
||||
* @param string $handle Name of the script to register a translation domain to.
|
||||
* @param string $domain The text domain.
|
||||
*/
|
||||
public static function filter_load_script_translation_file( $file, $handle, $domain ) {
|
||||
if ( false !== $file && isset( self::$domain_map[ $domain ] ) && ! is_readable( $file ) ) {
|
||||
// Determine the part of the filename after the domain.
|
||||
$suffix = basename( $file );
|
||||
$l = strlen( $domain );
|
||||
if ( substr( $suffix, 0, $l ) !== $domain || '-' !== $suffix[ $l ] ) {
|
||||
return $file;
|
||||
}
|
||||
$suffix = substr( $suffix, $l );
|
||||
$lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
|
||||
|
||||
// Look for replacement files.
|
||||
list( $newdomain, $type ) = self::$domain_map[ $domain ];
|
||||
$newfile = $lang_dir . ( 'core' === $type ? '/' : "/{$type}/" ) . $newdomain . $suffix;
|
||||
if ( is_readable( $newfile ) ) {
|
||||
return $newfile;
|
||||
}
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
|
||||
// endregion .
|
||||
}
|
||||
|
||||
// Enable section folding in vim:
|
||||
// vim: foldmarker=//\ region,//\ endregion foldmethod=marker
|
||||
// .
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* Jetpack script data.
|
||||
*
|
||||
* @package automattic/jetpack-assets
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Assets;
|
||||
|
||||
use Automattic\Jetpack\Assets;
|
||||
use Automattic\Jetpack\Status;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
|
||||
/**
|
||||
* Class script data
|
||||
*/
|
||||
class Script_Data {
|
||||
|
||||
const SCRIPT_HANDLE = 'jetpack-script-data';
|
||||
|
||||
/**
|
||||
* Whether the script data has been rendered.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $did_render_script_data = false;
|
||||
|
||||
/**
|
||||
* Configure.
|
||||
*/
|
||||
public static function configure() {
|
||||
/**
|
||||
* Ensure that assets are registered on wp_loaded,
|
||||
* which is fired before *_enqueue_scripts actions.
|
||||
* It means that when the dependent scripts are registered,
|
||||
* the scripts here are already registered.
|
||||
*/
|
||||
add_action( 'wp_loaded', array( self::class, 'register_assets' ) );
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* 1. wp_print_scripts action is fired on both admin and public pages.
|
||||
* On admin pages, it's fired before admin_enqueue_scripts action,
|
||||
* which can be a problem if the consumer package uses admin_enqueue_scripts
|
||||
* to hook into the script data. Thus, we prefer to use admin_print_scripts on admin pages.
|
||||
* 2. We want to render the script data on print, instead of init or enqueue actions,
|
||||
* so that the hook callbacks have enough time and information
|
||||
* to decide whether to update the script data or not.
|
||||
*/
|
||||
$hook = is_admin() ? 'admin_print_scripts' : 'wp_print_scripts';
|
||||
add_action( $hook, array( self::class, 'render_script_data' ), 1 );
|
||||
add_action( 'enqueue_block_editor_assets', array( self::class, 'render_script_data' ), 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register assets.
|
||||
*
|
||||
* @access private
|
||||
*/
|
||||
public static function register_assets() {
|
||||
|
||||
Assets::register_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
'../build/jetpack-script-data.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'in_footer' => true,
|
||||
'textdomain' => 'jetpack-assets',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the script data using an inline script.
|
||||
*
|
||||
* @access private
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function render_script_data() {
|
||||
// In case of 'enqueue_block_editor_assets' action, this can be called multiple times.
|
||||
if ( self::$did_render_script_data ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$did_render_script_data = true;
|
||||
|
||||
$script_data = is_admin() || self::is_authenticated_rest_request()
|
||||
? self::get_admin_script_data()
|
||||
: self::get_public_script_data();
|
||||
|
||||
if ( ! empty( $script_data ) ) {
|
||||
$script_data = wp_json_encode(
|
||||
$script_data,
|
||||
JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
wp_add_inline_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
sprintf( 'window.JetpackScriptData = %s;', $script_data ),
|
||||
'before'
|
||||
);
|
||||
Assets::enqueue_script( self::SCRIPT_HANDLE );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current request is an authenticated REST API request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected static function is_authenticated_rest_request() {
|
||||
return wp_is_serving_rest_request() && current_user_can( 'read' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the admin script data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_admin_script_data() {
|
||||
|
||||
global $wp_version;
|
||||
|
||||
$data = array(
|
||||
'site' => array(
|
||||
'admin_url' => esc_url_raw( admin_url() ),
|
||||
'date_format' => get_option( 'date_format' ),
|
||||
'icon' => self::get_site_icon(),
|
||||
'is_multisite' => is_multisite(),
|
||||
'host' => ( new Host() )->get_known_host_guess(),
|
||||
'is_wpcom_platform' => ( new Host() )->is_wpcom_platform(),
|
||||
'plan' => array(
|
||||
// The properties here should be updated by the consumer package/plugin.
|
||||
// It includes properties like 'product_slug', 'features', etc.
|
||||
'product_slug' => '',
|
||||
),
|
||||
'rest_nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
'rest_root' => esc_url_raw( rest_url() ),
|
||||
'suffix' => ( new Status() )->get_site_suffix(),
|
||||
'title' => self::get_site_title(),
|
||||
'wp_version' => $wp_version,
|
||||
'wpcom' => array(
|
||||
// This should contain the connected site details like blog_id, is_atomic etc.
|
||||
'blog_id' => 0,
|
||||
),
|
||||
),
|
||||
'user' => array(
|
||||
'current_user' => self::get_current_user_data(),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Filter the admin script data.
|
||||
*
|
||||
* When using this filter, ensure that the data is added only if it is used by some script.
|
||||
* This filter may be called on almost every admin page load. So, one should check if the data is needed/used on that page.
|
||||
* For example, the social (publicize) data is used only on Social admin page, Jetpack settings page and the post editor.
|
||||
* So, the social data should be added only on those pages.
|
||||
*
|
||||
* @since 2.3.0
|
||||
*
|
||||
* @param array $data The script data.
|
||||
*/
|
||||
return apply_filters( 'jetpack_admin_js_script_data', $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public script data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_public_script_data() {
|
||||
|
||||
$data = array();
|
||||
|
||||
/**
|
||||
* Filter the public script data.
|
||||
*
|
||||
* See the docs for `jetpack_admin_js_script_data` filter for more information.
|
||||
*
|
||||
* @since 2.3.0
|
||||
*
|
||||
* @param array $data The script data.
|
||||
*/
|
||||
return apply_filters( 'jetpack_public_js_script_data', $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site title.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_site_title() {
|
||||
$title = get_bloginfo( 'name' );
|
||||
|
||||
return $title ? $title : esc_url_raw( ( get_site_url() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site icon.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function get_site_icon() {
|
||||
if ( ! has_site_icon() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the site icon using Photon.
|
||||
*
|
||||
* @see https://developer.wordpress.com/docs/photon/
|
||||
*
|
||||
* @param string $url The URL of the site icon.
|
||||
* @param array|string $args An array of arguments, e.g. array( 'w' => '300', 'resize' => array( 123, 456 ) ), or in string form (w=123&h=456).
|
||||
*/
|
||||
return apply_filters( 'jetpack_photon_url', get_site_icon_url(), array( 'w' => 64 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user data.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_current_user_data() {
|
||||
$current_user = wp_get_current_user();
|
||||
|
||||
return array(
|
||||
'display_name' => $current_user->display_name,
|
||||
'id' => $current_user->ID,
|
||||
'capabilities' => array(
|
||||
'manage_options' => current_user_can( 'manage_options' ),
|
||||
'manage_modules' => current_user_can( 'jetpack_manage_modules' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
/**
|
||||
* Simple semver version handling.
|
||||
*
|
||||
* We use this instead of something like `composer/semver` to avoid
|
||||
* plugins needing to include yet-another dependency package. The
|
||||
* amount of code we need here is pretty small.
|
||||
*
|
||||
* We use this instead of PHP's `version_compare()` because that doesn't
|
||||
* handle prerelease versions in the way anyone other than PHP devs would
|
||||
* expect, and silently breaks on various unexpected input.
|
||||
*
|
||||
* @package automattic/jetpack-assets
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Assets;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Simple semver version handling.
|
||||
*/
|
||||
class Semver {
|
||||
/**
|
||||
* Parse a semver version.
|
||||
*
|
||||
* @param string $version Version.
|
||||
* @return array With components:
|
||||
* - major: (int) Major version.
|
||||
* - minor: (int) Minor version.
|
||||
* - patch: (int) Patch version.
|
||||
* - version: (string) Major.minor.patch.
|
||||
* - prerelease: (string|null) Pre-release string.
|
||||
* - buildinfo: (string|null) Build metadata string.
|
||||
* @throws InvalidArgumentException If the version number is not in a recognized format.
|
||||
*/
|
||||
public static function parse( $version ) {
|
||||
// This is slightly looser than the official version from semver.org, in that leading zeros are allowed.
|
||||
if ( ! preg_match( '/^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<prerelease>(?:[0-9a-zA-Z-]+)(?:\.(?:[0-9a-zA-Z-]+))*))?(?:\+(?P<buildinfo>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/', $version, $m ) ) {
|
||||
throw new InvalidArgumentException( "Version number \"$version\" is not in a recognized format." );
|
||||
}
|
||||
$info = array(
|
||||
'major' => (int) $m['major'],
|
||||
'minor' => (int) $m['minor'],
|
||||
'patch' => (int) $m['patch'],
|
||||
'version' => sprintf( '%d.%d.%d', $m['major'], $m['minor'], $m['patch'] ),
|
||||
'prerelease' => isset( $m['prerelease'] ) && '' !== $m['prerelease'] ? $m['prerelease'] : null,
|
||||
'buildinfo' => isset( $m['buildinfo'] ) && '' !== $m['buildinfo'] ? $m['buildinfo'] : null,
|
||||
);
|
||||
|
||||
if ( null !== $info['prerelease'] ) {
|
||||
$sep = '';
|
||||
$prerelease = '';
|
||||
foreach ( explode( '.', $info['prerelease'] ) as $part ) {
|
||||
if ( ctype_digit( $part ) ) {
|
||||
$part = (int) $part;
|
||||
}
|
||||
$prerelease .= $sep . $part;
|
||||
$sep = '.';
|
||||
}
|
||||
$info['prerelease'] = $prerelease;
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version numbers.
|
||||
*
|
||||
* @param string $a First version.
|
||||
* @param string $b Second version.
|
||||
* @return int Less than, equal to, or greater than 0 depending on whether `$a` is less than, equal to, or greater than `$b`.
|
||||
* @throws InvalidArgumentException If the version numbers are not in a recognized format.
|
||||
*/
|
||||
public static function compare( $a, $b ) {
|
||||
$aa = self::parse( $a );
|
||||
$bb = self::parse( $b );
|
||||
if ( $aa['major'] !== $bb['major'] ) {
|
||||
return $aa['major'] - $bb['major'];
|
||||
}
|
||||
if ( $aa['minor'] !== $bb['minor'] ) {
|
||||
return $aa['minor'] - $bb['minor'];
|
||||
}
|
||||
if ( $aa['patch'] !== $bb['patch'] ) {
|
||||
return $aa['patch'] - $bb['patch'];
|
||||
}
|
||||
|
||||
if ( null === $aa['prerelease'] ) {
|
||||
return null === $bb['prerelease'] ? 0 : 1;
|
||||
}
|
||||
if ( null === $bb['prerelease'] ) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$aaa = explode( '.', $aa['prerelease'] );
|
||||
$bbb = explode( '.', $bb['prerelease'] );
|
||||
$al = count( $aaa );
|
||||
$bl = count( $bbb );
|
||||
for ( $i = 0; $i < $al && $i < $bl; $i++ ) {
|
||||
$a = $aaa[ $i ];
|
||||
$b = $bbb[ $i ];
|
||||
if ( ctype_digit( $a ) ) {
|
||||
if ( ctype_digit( $b ) ) {
|
||||
if ( (int) $a !== (int) $b ) {
|
||||
return (int) $a - (int) $b;
|
||||
}
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} elseif ( ctype_digit( $b ) ) {
|
||||
return 1;
|
||||
} else {
|
||||
$tmp = strcmp( $a, $b );
|
||||
if ( 0 !== $tmp ) {
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $al - $bl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Jetpack shared stores assets.
|
||||
*
|
||||
* @package automattic/jetpack-assets
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Assets;
|
||||
|
||||
use Automattic\Jetpack\Assets;
|
||||
|
||||
/**
|
||||
* Registers the externalized jetpack-shared-stores bundle so that consuming
|
||||
* packages can declare it as a WordPress script dependency. Loading the bundle
|
||||
* once ensures the contained data stores register exactly once per page.
|
||||
*/
|
||||
class Shared_Stores_Assets {
|
||||
|
||||
const SCRIPT_HANDLE = 'jetpack-shared-stores';
|
||||
|
||||
/**
|
||||
* Configure.
|
||||
*/
|
||||
public static function configure() {
|
||||
add_action( 'wp_loaded', array( self::class, 'register_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register assets.
|
||||
*/
|
||||
public static function register_assets() {
|
||||
Assets::register_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
'../build/jetpack-shared-stores.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'in_footer' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
const i18n = require( '@wordpress/i18n' );
|
||||
const { default: md5 } = require( 'md5-es' );
|
||||
|
||||
const locationMap = {
|
||||
plugin: 'plugins/',
|
||||
theme: 'themes/',
|
||||
core: '',
|
||||
};
|
||||
|
||||
const hasOwn = ( obj, prop ) => Object.prototype.hasOwnProperty.call( obj, prop );
|
||||
|
||||
module.exports = {
|
||||
state: {
|
||||
baseUrl: null,
|
||||
locale: null,
|
||||
domainMap: {},
|
||||
domainPaths: {},
|
||||
},
|
||||
|
||||
/**
|
||||
* Download and register translations for a bundle.
|
||||
*
|
||||
* @param {string} path - Bundle path being fetched. May have a query part.
|
||||
* @param {string} domain - Text domain to register into.
|
||||
* @param {string} location - Location for the translation: 'plugin', 'theme', or 'core'.
|
||||
* @return {Promise} Resolved when the translations are registered, or rejected with an `Error`.
|
||||
*/
|
||||
async downloadI18n( path, domain, location ) {
|
||||
const state = this.state;
|
||||
if ( ! state || typeof state.baseUrl !== 'string' ) {
|
||||
throw new Error( 'wp.jpI18nLoader.state is not set' );
|
||||
}
|
||||
|
||||
// "en_US" is the default, no translations are needed.
|
||||
if ( state.locale === 'en_US' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that fetch is available.
|
||||
if ( typeof fetch === 'undefined' ) {
|
||||
throw new Error( 'Fetch API is not available.' );
|
||||
}
|
||||
|
||||
// Extract any query part and hash the script name like WordPress does.
|
||||
const pathPrefix = hasOwn( state.domainPaths, domain ) ? state.domainPaths[ domain ] : '';
|
||||
let hash, query;
|
||||
const i = path.indexOf( '?' );
|
||||
if ( i >= 0 ) {
|
||||
hash = md5.hash( pathPrefix + path.substring( 0, i ) );
|
||||
query = path.substring( i );
|
||||
} else {
|
||||
hash = md5.hash( pathPrefix + path );
|
||||
query = '';
|
||||
}
|
||||
|
||||
// Download.
|
||||
const locationAndDomain = hasOwn( state.domainMap, domain )
|
||||
? state.domainMap[ domain ]
|
||||
: locationMap[ location ] + domain;
|
||||
const res = await fetch(
|
||||
// prettier-ignore
|
||||
`${ state.baseUrl }${ locationAndDomain }-${ state.locale }-${ hash }.json${ query }`
|
||||
);
|
||||
if ( ! res.ok ) {
|
||||
throw new Error( `HTTP request failed: ${ res.status } ${ res.statusText }` );
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
// Extract the messages from the file and register them.
|
||||
const localeData = hasOwn( data.locale_data, domain )
|
||||
? data.locale_data[ domain ]
|
||||
: data.locale_data.messages;
|
||||
localeData[ '' ].domain = domain;
|
||||
i18n.setLocaleData( localeData, domain );
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@automattic/jetpack-script-data';
|
||||
@@ -0,0 +1 @@
|
||||
export * from '@automattic/jetpack-shared-stores';
|
||||
@@ -0,0 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.11] - 2026-06-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.10] - 2026-05-19
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.9] - 2026-05-04
|
||||
### Changed
|
||||
- Internal: No longer require automattic/jetpack-changelogger as a per-project dev dependency. [#48225]
|
||||
|
||||
## [0.3.8] - 2025-09-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.7] - 2025-04-28
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.6] - 2025-03-24
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.5] - 2025-03-12
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.4] - 2025-03-10
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.3] - 2025-02-24
|
||||
### Changed
|
||||
- Update dependencies.
|
||||
|
||||
## [0.3.2] - 2025-02-03
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.3.1] - 2024-11-25
|
||||
### Changed
|
||||
- Update dependencies. [#40286]
|
||||
|
||||
## [0.3.0] - 2024-11-14
|
||||
### Changed
|
||||
- Backup: Add next daily backup schedule time on admin page. [#39914]
|
||||
|
||||
### Removed
|
||||
- General: Update minimum PHP version to 7.2. [#40147]
|
||||
|
||||
## [0.2.8] - 2024-11-04
|
||||
### Added
|
||||
- Enable test coverage. [#39961]
|
||||
|
||||
## [0.2.7] - 2024-08-26
|
||||
### Changed
|
||||
- Updated package dependencies. [#39004]
|
||||
|
||||
## [0.2.6] - 2024-04-08
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.5] - 2024-03-25
|
||||
### Fixed
|
||||
- Backup: change some error messages to not trigger security scanners [#36496]
|
||||
|
||||
## [0.2.4] - 2024-03-18
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.3] - 2024-03-14
|
||||
### Changed
|
||||
- Internal updates.
|
||||
|
||||
## [0.2.2] - 2024-02-27
|
||||
### Added
|
||||
- Increasing backup version for new endpoint [#35649]
|
||||
|
||||
## [0.2.1] - 2024-02-08
|
||||
### Fixed
|
||||
- Write helper script to ABSPATH by default, just like we did before [#35508]
|
||||
|
||||
## [0.2.0] - 2024-01-04
|
||||
### Fixed
|
||||
- Backup: Add namespace versioning to Helper_Script_Manager and other classes. [#34739]
|
||||
|
||||
## 0.1.0 - 2023-12-13
|
||||
### Fixed
|
||||
- Initial release (improved helper script installer logging). [#34297]
|
||||
|
||||
[0.3.11]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.9...v0.3.10
|
||||
[0.3.9]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.8...v0.3.9
|
||||
[0.3.8]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.7...v0.3.8
|
||||
[0.3.7]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.8...v0.3.0
|
||||
[0.2.8]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.7...v0.2.8
|
||||
[0.2.7]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.6...v0.2.7
|
||||
[0.2.6]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.5...v0.2.6
|
||||
[0.2.5]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.4...v0.2.5
|
||||
[0.2.4]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/Automattic/jetpack-backup-helper-script-manager/compare/v0.1.0...v0.2.0
|
||||
@@ -0,0 +1,620 @@
|
||||
<?php
|
||||
/**
|
||||
* The Jetpack Backup Helper Script Manager class (implementation).
|
||||
*
|
||||
* @package automattic/jetpack-backup
|
||||
*/
|
||||
|
||||
// After changing this file, consider increasing the version number ("VXXX") in all the files using this namespace, in
|
||||
// order to ensure that the specific version of this file always get loaded. Otherwise, Jetpack autoloader might decide
|
||||
// to load an older/newer version of the class (if, for example, both the standalone and bundled versions of the plugin
|
||||
// are installed, or in some other cases).
|
||||
namespace Automattic\Jetpack\Backup\V0005;
|
||||
|
||||
use Exception;
|
||||
use WP_Error;
|
||||
use function content_url;
|
||||
use function get_site_url;
|
||||
use function is_wp_error;
|
||||
use function set_url_scheme;
|
||||
use function trailingslashit;
|
||||
use function wp_generate_password;
|
||||
use function wp_http_validate_url;
|
||||
use function wp_schedule_single_event;
|
||||
use function wp_upload_dir;
|
||||
use const ABSPATH;
|
||||
use const WP_CONTENT_DIR;
|
||||
|
||||
/**
|
||||
* Manage installation, deletion and cleanup of Helper Scripts to assist with backing up Jetpack Sites.
|
||||
*
|
||||
* Does *not* use WP_Filesystem, because if there are permissions issues between the webserver's user and the FTP/SSH
|
||||
* user, then we'll just install the helper script and do a backup/restore using FTP/SSH credentials (that we collect
|
||||
* ourselves), without using WP_Filesystem in any way.
|
||||
*
|
||||
* Also, if we can't write that helper script somewhere (due to writes being inaccessible to the webserver's user, or
|
||||
* for other reasons), we want to know about it (in the form of an error response), instead of having that helper
|
||||
* script silently uploaded via FTP/SFTP, so that we could fall back to a backup/restore using credentials.
|
||||
*
|
||||
* Lastly, PHP provides us with better error reporting than WP_Filesystem.
|
||||
*/
|
||||
class Helper_Script_Manager_Impl {
|
||||
|
||||
/**
|
||||
* Name of a directory that will be created for storing the helper script.
|
||||
*/
|
||||
const TEMP_DIRECTORY = 'jetpack-temp';
|
||||
|
||||
/**
|
||||
* How long until the helper script will "expire" and refuse taking requests, in seconds.
|
||||
*/
|
||||
const EXPIRY_TIME = 60 * 60 * 8;
|
||||
|
||||
/**
|
||||
* Maximum size of the helper script, in bytes.
|
||||
*/
|
||||
const MAX_FILESIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Associative array of possible places to install a jetpack-temp directory, along with the URL to access each.
|
||||
*
|
||||
* Keys specify the full path of install locations, and values point to the equivalent URL.
|
||||
*
|
||||
* If null, then install locations will be determined dynamically at the point of an install.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
protected $custom_install_locations;
|
||||
|
||||
/**
|
||||
* Filenames to ignore in scandir()'s return value.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $scandir_ignored_names = array( '.', '..' );
|
||||
|
||||
/**
|
||||
* Header that the helper script is expected to start with.
|
||||
*/
|
||||
const HELPER_HEADER = "<?php /* Jetpack Backup Helper Script */\n";
|
||||
|
||||
/**
|
||||
* Lines that will be written to README in the helper directory.
|
||||
*/
|
||||
const README_LINES = array(
|
||||
'These files have been put on your server by Jetpack to assist with backups, restores, and scans of your ' .
|
||||
'site content. They are cleaned up automatically when we no longer need them.',
|
||||
'If you no longer have Jetpack connected to your site, you can delete them manually.',
|
||||
'If you have questions or need assistance, please contact Jetpack Support at https://jetpack.com/support/',
|
||||
'If you like to build amazing things with WordPress, you should visit automattic.com/jobs and apply to join ' .
|
||||
'the fun – mention this file when you apply!',
|
||||
);
|
||||
|
||||
/**
|
||||
* Data that will be written to index.php in the helper directory.
|
||||
*/
|
||||
const INDEX_FILE = '<?php // Silence is golden';
|
||||
|
||||
/**
|
||||
* Create Helper Script Manager.
|
||||
*
|
||||
* @param array|null $custom_install_locations Associative array of possible places to install a jetpack-temp
|
||||
* directory, along with the URL to access each.
|
||||
*/
|
||||
public function __construct( $custom_install_locations = null ) {
|
||||
$this->custom_install_locations = $custom_install_locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get either the default install locations, or the ones configured in the constructor.
|
||||
*
|
||||
* Has to be done late, i.e. can't be done in constructor, because in __construct() not all constants / functions
|
||||
* might be available.
|
||||
*
|
||||
* @return array<string, string|WP_Error> Array with keys specifying the full path of install locations, and values
|
||||
* either pointing to the equivalent URL, or being WP_Error if a specific path is not accessible.
|
||||
*/
|
||||
public function install_locations() {
|
||||
if ( $this->custom_install_locations !== null ) {
|
||||
return $this->custom_install_locations;
|
||||
}
|
||||
|
||||
$abspath_url = get_site_url();
|
||||
|
||||
$locations = array();
|
||||
|
||||
// Prioritize ABSPATH first, because even though ABSPATH constant's value might be weird sometimes, it's the
|
||||
// path where the PHP scripts will be most likely be able to get executed.
|
||||
|
||||
try {
|
||||
if ( Throw_On_Errors::t_is_dir( ABSPATH ) ) {
|
||||
$abspath_dir = Throw_On_Errors::t_realpath( ABSPATH );
|
||||
$locations[ $abspath_dir ] = $abspath_url;
|
||||
}
|
||||
} catch ( Exception $exception ) {
|
||||
$locations[ ABSPATH ] = new WP_Error(
|
||||
'abspath_missing',
|
||||
'Unable to access WordPress root "' . ABSPATH . '": ' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if ( Throw_On_Errors::t_is_dir( WP_CONTENT_DIR ) ) {
|
||||
$wp_content_dir = Throw_On_Errors::t_realpath( WP_CONTENT_DIR );
|
||||
|
||||
// Using content_url() instead of WP_CONTENT_URL as it tests for whether we're using SSL.
|
||||
$wp_content_url = content_url();
|
||||
|
||||
// I think we mess up the order in which we load things somewhere in a test, so "wp-content" and
|
||||
// "wp-content/uploads/" URLs don't actually have the scheme+host part in them.
|
||||
if ( ! wp_http_validate_url( $wp_content_url ) ) {
|
||||
$wp_content_url = $abspath_url . $wp_content_url;
|
||||
}
|
||||
|
||||
$locations[ $wp_content_dir ] = $wp_content_url;
|
||||
}
|
||||
} catch ( Exception $exception ) {
|
||||
$locations[ WP_CONTENT_DIR ] = new WP_Error(
|
||||
'content_path_missing',
|
||||
'Unable to access content path "' . WP_CONTENT_DIR . '"' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
$upload_dir_info = wp_upload_dir();
|
||||
$wp_uploads_dir = $upload_dir_info['basedir'];
|
||||
|
||||
try {
|
||||
if ( Throw_On_Errors::t_is_dir( $wp_uploads_dir ) ) {
|
||||
|
||||
$wp_uploads_dir = Throw_On_Errors::t_realpath( $wp_uploads_dir );
|
||||
$wp_uploads_url = $upload_dir_info['baseurl'];
|
||||
|
||||
// wp_upload_dir() doesn't check for whether we're using SSL:
|
||||
//
|
||||
// https://core.trac.wordpress.org/ticket/25449
|
||||
//
|
||||
// so set the scheme manually.
|
||||
$wp_uploads_url = set_url_scheme( $wp_uploads_url );
|
||||
|
||||
if ( ! wp_http_validate_url( $wp_uploads_url ) ) {
|
||||
$wp_uploads_url = $abspath_url . $wp_uploads_url;
|
||||
}
|
||||
|
||||
$locations[ $wp_uploads_dir ] = $wp_uploads_url;
|
||||
}
|
||||
} catch ( Exception $exception ) {
|
||||
$locations[ $wp_uploads_dir ] = new WP_Error(
|
||||
'uploads_path_missing',
|
||||
'Unable to access uploads path "' . $wp_uploads_dir . '"' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return $locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a Helper Script, and returns its filesystem path and access url.
|
||||
*
|
||||
* @param string $script_body Helper Script file contents.
|
||||
*
|
||||
* @return array|WP_Error Either an array containing the filesystem path ("path"), the URL ("url") of the helper
|
||||
* script, and the WordPress root ("abspath"), or an instance of WP_Error.
|
||||
*/
|
||||
public function install_helper_script( $script_body ) {
|
||||
// Check that the script body contains the correct header.
|
||||
$actual_header = static::string_starts_with_substring( $script_body, static::HELPER_HEADER );
|
||||
if ( true !== $actual_header ) {
|
||||
return new WP_Error(
|
||||
'bad_header',
|
||||
'Bad helper script header: 0x' . bin2hex( $actual_header ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse to install a Helper Script that is too large.
|
||||
$helper_script_size = strlen( $script_body );
|
||||
if ( $helper_script_size > static::MAX_FILESIZE ) {
|
||||
return new WP_Error(
|
||||
'too_big',
|
||||
"Helper script is bigger ($helper_script_size bytes) " .
|
||||
'than the max. size (' . static::MAX_FILESIZE . ' bytes)',
|
||||
array( 'status' => 413 )
|
||||
);
|
||||
}
|
||||
|
||||
// Replace '[wp_path]' in the Helper Script with the WordPress installation location. Allows the Helper Script
|
||||
// to find WordPress.
|
||||
$wp_path_marker = '[wp_path]';
|
||||
try {
|
||||
$normalized_abspath = addslashes( Throw_On_Errors::t_realpath( ABSPATH ) );
|
||||
} catch ( Exception $exception ) {
|
||||
return new WP_Error(
|
||||
'abspath_missing',
|
||||
'Error while resolving ABSPATH "' . ABSPATH . '": ' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
$script_body = str_replace(
|
||||
$wp_path_marker,
|
||||
$normalized_abspath,
|
||||
$script_body,
|
||||
$wp_path_marker_replacement_count
|
||||
);
|
||||
if ( 0 === $wp_path_marker_replacement_count ) {
|
||||
return new WP_Error(
|
||||
'no_wp_path_marker',
|
||||
"Helper script does not have the '$wp_path_marker' marker",
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$failure_paths_and_reasons = array();
|
||||
|
||||
foreach ( $this->install_locations() as $directory => $url ) {
|
||||
|
||||
if ( is_wp_error( $url ) ) {
|
||||
$failure_paths_and_reasons[] = "directory '$directory': " . $url->get_error_message();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$installed = $this->install_to_location_or_throw( $script_body, $directory, $url );
|
||||
|
||||
// Always schedule a cleanup run shortly after EXPIRY_TIME.
|
||||
wp_schedule_single_event(
|
||||
time() + static::EXPIRY_TIME + 60,
|
||||
'jetpack_backup_cleanup_helper_scripts'
|
||||
);
|
||||
|
||||
return array(
|
||||
'path' => $installed['path'],
|
||||
'url' => $installed['url'],
|
||||
'abspath' => Throw_On_Errors::t_realpath( ABSPATH ),
|
||||
);
|
||||
|
||||
} catch ( Exception $exception ) {
|
||||
$failure_paths_and_reasons[] = "directory '$directory' (URL '$url'): " . $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'all_locations_failed',
|
||||
'Unable to write the helper script to any install locations; ' .
|
||||
'tried: ' . implode( ';', $failure_paths_and_reasons ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install helper script to a directory, or throw an exception.
|
||||
*
|
||||
* @param string $script_body Helper script's body.
|
||||
* @param string $directory Candidate directory to create "jetpack-temp" in and write the helper script.
|
||||
* @param string $url Base URL that the files in a directory are expected to be available at.
|
||||
*
|
||||
* @return string[] Array with "path" (location to the installed helper script) and "url"
|
||||
* (URL of the installed helper script) keys.
|
||||
* @throws Exception On I/O errors.
|
||||
*/
|
||||
protected function install_to_location_or_throw( $script_body, $directory, $url ) {
|
||||
if ( ! Throw_On_Errors::t_is_writable( $directory ) ) {
|
||||
throw new Exception( "Directory '$directory' is not writable" );
|
||||
}
|
||||
|
||||
$temp_dir = trailingslashit( $directory ) . static::TEMP_DIRECTORY;
|
||||
|
||||
if ( ! Throw_On_Errors::t_is_dir( $temp_dir ) ) {
|
||||
Throw_On_Errors::t_mkdir( $temp_dir );
|
||||
}
|
||||
|
||||
$readme_path = trailingslashit( $temp_dir ) . 'README';
|
||||
Throw_On_Errors::t_file_put_contents( $readme_path, implode( "\n\n", static::README_LINES ) );
|
||||
|
||||
$index_path = trailingslashit( $temp_dir ) . 'index.php';
|
||||
Throw_On_Errors::t_file_put_contents( $index_path, static::INDEX_FILE );
|
||||
|
||||
$file_key = wp_generate_password( 10, false );
|
||||
$file_name = 'jp-helper-' . $file_key . '.php';
|
||||
$file_path = trailingslashit( $temp_dir ) . $file_name;
|
||||
|
||||
// Very unlikely, but check nonetheless.
|
||||
if ( Throw_On_Errors::t_file_exists( $file_path ) ) {
|
||||
throw new Exception( "Helper script at '$file_path' already exists" );
|
||||
}
|
||||
|
||||
Throw_On_Errors::t_file_put_contents( $file_path, $script_body );
|
||||
|
||||
return array(
|
||||
'path' => $file_path,
|
||||
'url' => trailingslashit( $url ) . trailingslashit( static::TEMP_DIRECTORY ) . $file_name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the helper script is gone (by deleting it, if needed).
|
||||
*
|
||||
* @param string $path Path to the helper script to delete.
|
||||
*
|
||||
* @return true|WP_Error True if the file helper script is gone (either it got deleted, or it was never there), or
|
||||
* WP_Error instance on deletion failures.
|
||||
*/
|
||||
public function delete_helper_script( $path ) {
|
||||
try {
|
||||
$this->delete_helper_script_or_throw( $path );
|
||||
} catch ( Exception $exception ) {
|
||||
return new WP_Error(
|
||||
'deletion_failure',
|
||||
"Unable to delete helper script at '$path': " . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the helper script is gone (by deleting it, if needed), throw an exception on errors.
|
||||
*
|
||||
* @param string $path Path to the helper script to delete.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception On deletion failures.
|
||||
*/
|
||||
protected function delete_helper_script_or_throw( $path ) {
|
||||
|
||||
if ( ! Throw_On_Errors::t_file_exists( $path ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! Throw_On_Errors::t_is_readable( $path ) ) {
|
||||
throw new Exception( "File '$path' is not readable" );
|
||||
}
|
||||
|
||||
if ( ! Throw_On_Errors::t_is_writable( $path ) ) {
|
||||
throw new Exception( "File '$path' is not writable" );
|
||||
}
|
||||
|
||||
$helper_script_size = Throw_On_Errors::t_filesize( $path );
|
||||
|
||||
// Check this file looks like a JPR helper script.
|
||||
$helper_header_size = strlen( static::HELPER_HEADER );
|
||||
if ( $helper_script_size < $helper_header_size ) {
|
||||
throw new Exception(
|
||||
"Helper script is smaller ($helper_script_size bytes) " .
|
||||
"than the expected header ($helper_header_size bytes)"
|
||||
);
|
||||
}
|
||||
if ( $helper_script_size > static::MAX_FILESIZE ) {
|
||||
throw new Exception(
|
||||
"Helper script is bigger ($helper_script_size bytes) " .
|
||||
'than the max. size (' . static::MAX_FILESIZE . ' bytes)'
|
||||
);
|
||||
}
|
||||
|
||||
$actual_header = static::verify_file_header( $path, static::HELPER_HEADER );
|
||||
if ( true !== $actual_header ) {
|
||||
throw new Exception( 'Bad helper script header: 0x' . bin2hex( $actual_header ) );
|
||||
}
|
||||
|
||||
Throw_On_Errors::t_unlink( $path );
|
||||
|
||||
$this->delete_helper_directory_if_empty( dirname( $path ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for Helper Scripts that are suspiciously old, and clean them out.
|
||||
*
|
||||
* @return true|WP_Error True if all expired helper scripts got cleaned up successfully, or an instance of
|
||||
* WP_Error if one or more expired helper scripts didn't manage to get cleaned up.
|
||||
*/
|
||||
public function cleanup_expired_helper_scripts() {
|
||||
try {
|
||||
$this->cleanup_helper_scripts( time() - static::EXPIRY_TIME );
|
||||
} catch ( Exception $exception ) {
|
||||
return new WP_Error(
|
||||
'cleanup_failed',
|
||||
'Unable to clean up expired helper scripts: ' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for and delete all Helper Scripts. Used during uninstallation.
|
||||
*
|
||||
* @return true|WP_Error True if all helper scripts got deleted successfully, or an instance of WP_Error if one or
|
||||
* more helper scripts didn't manage to get deleted.
|
||||
*/
|
||||
public function delete_all_helper_scripts() {
|
||||
try {
|
||||
$this->cleanup_helper_scripts();
|
||||
} catch ( Exception $exception ) {
|
||||
return new WP_Error(
|
||||
'cleanup_failed',
|
||||
'Unable to clean up all helper scripts: ' . $exception->getMessage(),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for and delete Helper Scripts. If an $expiry_time is specified, only delete Helper Scripts
|
||||
* with a mtime older than $expiry_time. Otherwise, delete them all.
|
||||
*
|
||||
* @param int|null $expiry_time If specified, only delete scripts older than this UNIX timestamp.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception If one or more helper scripts doesn't manage to get cleaned up.
|
||||
*/
|
||||
protected function cleanup_helper_scripts( $expiry_time = null ) {
|
||||
|
||||
$error_messages = array();
|
||||
|
||||
foreach ( $this->install_locations() as $directory => $url ) {
|
||||
|
||||
if ( is_wp_error( $url ) ) {
|
||||
$error_messages[] = $url->get_error_message();
|
||||
continue;
|
||||
}
|
||||
|
||||
$temp_dir = trailingslashit( trailingslashit( $directory ) . static::TEMP_DIRECTORY );
|
||||
|
||||
if ( Throw_On_Errors::t_is_dir( $temp_dir ) ) {
|
||||
|
||||
// Find expired helper scripts and delete them.
|
||||
$temp_dir_contents = Throw_On_Errors::t_scandir( $temp_dir );
|
||||
|
||||
foreach ( $temp_dir_contents as $name ) {
|
||||
|
||||
if ( in_array( $name, $this->scandir_ignored_names, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_path = $temp_dir . $name;
|
||||
|
||||
$last_modified = Throw_On_Errors::t_filemtime( $full_path );
|
||||
|
||||
if ( preg_match( '/^jp-helper-.*\.php$/', $name ) ) {
|
||||
if ( null === $expiry_time || $last_modified < $expiry_time ) {
|
||||
try {
|
||||
$this->delete_helper_script_or_throw( $full_path );
|
||||
} catch ( Exception $exception ) {
|
||||
$error_messages[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the directory if it's empty now.
|
||||
$this->delete_helper_directory_if_empty( $temp_dir );
|
||||
}
|
||||
}
|
||||
|
||||
if ( count( $error_messages ) > 0 ) {
|
||||
throw new Exception(
|
||||
'Unable to clean up one or more helper scripts: ' . implode( ';', $error_messages )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a helper script directory if it's empty.
|
||||
*
|
||||
* @param string $dir Path to the helper script directory.
|
||||
*
|
||||
* @return bool True if the directory is missing, or was empty and got deleted; false if directory still contains
|
||||
* something and wasn't deleted.
|
||||
* @throws Exception On I/O errors.
|
||||
*/
|
||||
protected function delete_helper_directory_if_empty( $dir ) {
|
||||
|
||||
if ( ! Throw_On_Errors::t_is_dir( $dir ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check that the only remaining files are a README and index.php generated by this system.
|
||||
$allowed_files_and_headers = array(
|
||||
'README' => static::README_LINES[0],
|
||||
'index.php' => static::INDEX_FILE,
|
||||
);
|
||||
|
||||
$dir_contents = Throw_On_Errors::t_scandir( $dir );
|
||||
|
||||
if ( count( $dir_contents ) > count( $allowed_files_and_headers ) + count( $this->scandir_ignored_names ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $dir_contents as $name ) {
|
||||
|
||||
if ( in_array( $name, $this->scandir_ignored_names, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$full_path = trailingslashit( $dir ) . $name;
|
||||
if ( ! isset( $allowed_files_and_headers[ $name ] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the file starts with the expected contents.
|
||||
$actual_header = static::verify_file_header( $full_path, $allowed_files_and_headers[ $name ] );
|
||||
if ( true !== $actual_header ) {
|
||||
throw new Exception( "Bad header for file '$full_path': 0x" . bin2hex( $actual_header ) );
|
||||
}
|
||||
|
||||
Throw_On_Errors::t_unlink( $full_path );
|
||||
}
|
||||
|
||||
// If the directory is now empty, delete it.
|
||||
$dir_contents_after_cleanup = Throw_On_Errors::t_scandir( $dir );
|
||||
|
||||
if ( count( $dir_contents_after_cleanup ) <= count( $this->scandir_ignored_names ) ) {
|
||||
Throw_On_Errors::t_rmdir( $dir );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if string starts with a substring, and if it doesn't, return the actual prefix.
|
||||
*
|
||||
* @param string $string String to search in.
|
||||
* @param string $expected_prefix Expected prefix.
|
||||
*
|
||||
* @return bool|string True if string starts with a substring, or the actual prefix that was found instead of the
|
||||
* expected prefix.
|
||||
*/
|
||||
protected static function string_starts_with_substring( $string, $expected_prefix ) {
|
||||
$actual_prefix = substr( $string, 0, strlen( $expected_prefix ) );
|
||||
if ( $actual_prefix !== $expected_prefix ) {
|
||||
return $actual_prefix;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a file exists, is readable, and has the expected header.
|
||||
*
|
||||
* @param string $path File to verify.
|
||||
* @param string $expected_header Header that the file should have.
|
||||
*
|
||||
* @return bool|string True if header matches, or an actual header if it doesn't match.
|
||||
* @throws Exception If the file doesn't exist, isn't readable, or is of the wrong size.
|
||||
*/
|
||||
protected static function verify_file_header( $path, $expected_header ) {
|
||||
if ( ! Throw_On_Errors::t_file_exists( $path ) ) {
|
||||
throw new Exception( "File '$path' does not exist" );
|
||||
}
|
||||
|
||||
if ( ! Throw_On_Errors::t_is_readable( $path ) ) {
|
||||
throw new Exception( "File '$path' is not readable" );
|
||||
}
|
||||
|
||||
$file_size = Throw_On_Errors::t_filesize( $path );
|
||||
|
||||
// Check this file looks like a JPR helper script.
|
||||
$expected_header_size = strlen( $expected_header );
|
||||
if ( $file_size < $expected_header_size ) {
|
||||
throw new Exception(
|
||||
"File is smaller ($file_size bytes) " .
|
||||
"than the expected header ($expected_header_size bytes)"
|
||||
);
|
||||
}
|
||||
if ( $file_size > static::MAX_FILESIZE ) {
|
||||
throw new Exception(
|
||||
"File is bigger ($file_size bytes) " .
|
||||
'than the max. size (' . static::MAX_FILESIZE . ' bytes)'
|
||||
);
|
||||
}
|
||||
|
||||
$file_contents = Throw_On_Errors::t_file_get_contents( $path );
|
||||
return static::string_starts_with_substring( $file_contents, $expected_header );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* Jetpack Backup Helper Script Manager class (static wrapper).
|
||||
*
|
||||
* @package automattic/jetpack-backup
|
||||
*/
|
||||
|
||||
// After changing this file, consider increasing the version number ("VXXX") in all the files using this namespace, in
|
||||
// order to ensure that the specific version of this file always get loaded. Otherwise, Jetpack autoloader might decide
|
||||
// to load an older/newer version of the class (if, for example, both the standalone and bundled versions of the plugin
|
||||
// are installed, or in some other cases).
|
||||
namespace Automattic\Jetpack\Backup\V0005;
|
||||
|
||||
/**
|
||||
* Manage installation, deletion and cleanup of Helper Scripts to assist with backing up Jetpack Sites.
|
||||
*
|
||||
* A static wrapper around an "implementation" class so that it gets autoloaded late, and we always get the latest
|
||||
* version of the class instead of a random version of it:
|
||||
*
|
||||
* https://github.com/Automattic/jetpack/pull/34297#discussion_r1424227489
|
||||
*/
|
||||
class Helper_Script_Manager {
|
||||
|
||||
/**
|
||||
* Instance of helper script manager implementation, or null if not initialized yet.
|
||||
*
|
||||
* @var Helper_Script_Manager_Impl|null
|
||||
*/
|
||||
protected static $impl = null;
|
||||
|
||||
/**
|
||||
* Initialize an instance of helper script manager implementation (if needed).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected static function initialize_impl_if_needed() {
|
||||
if ( null === static::$impl ) {
|
||||
static::$impl = new Helper_Script_Manager_Impl();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a Helper Script, and returns its filesystem path and access url.
|
||||
*
|
||||
* @param string $script_body Helper Script file contents.
|
||||
*
|
||||
* @return array|\WP_Error Either an array containing the filesystem path ("path"), the URL ("url") of the helper
|
||||
* script, and the WordPress root ("abspath"), or an instance of WP_Error.
|
||||
*/
|
||||
public static function install_helper_script( $script_body ) {
|
||||
static::initialize_impl_if_needed();
|
||||
return static::$impl->install_helper_script( $script_body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the helper script is gone (by deleting it, if needed).
|
||||
*
|
||||
* @param string $path Path to the helper script to delete.
|
||||
*
|
||||
* @return true|\WP_Error True if the file helper script is gone (either it got deleted, or it was never there), or
|
||||
* WP_Error instance on deletion failures.
|
||||
*/
|
||||
public static function delete_helper_script( $path ) {
|
||||
static::initialize_impl_if_needed();
|
||||
return static::$impl->delete_helper_script( $path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for Helper Scripts that are suspiciously old, and clean them out.
|
||||
*
|
||||
* @return true|\WP_Error True if all expired helper scripts got cleaned up successfully, or an instance of
|
||||
* WP_Error if one or more expired helper scripts didn't manage to get cleaned up.
|
||||
*/
|
||||
public static function cleanup_expired_helper_scripts() {
|
||||
static::initialize_impl_if_needed();
|
||||
return static::$impl->cleanup_expired_helper_scripts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for and delete all Helper Scripts. Used during uninstallation.
|
||||
*
|
||||
* @return true|\WP_Error True if all helper scripts got deleted successfully, or an instance of WP_Error if one or
|
||||
* more helper scripts didn't manage to get deleted.
|
||||
*/
|
||||
public static function delete_all_helper_scripts() {
|
||||
static::initialize_impl_if_needed();
|
||||
return static::$impl->delete_all_helper_scripts();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php // phpcs:disable Squiz.Commenting.FileComment.Missing
|
||||
|
||||
// phpcs:disable WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting
|
||||
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting
|
||||
// phpcs:disable WordPress.PHP.IniSet.display_errors_Disallowed
|
||||
|
||||
// After changing this file, consider increasing the version number ("VXXX") in all the files using this namespace, in
|
||||
// order to ensure that the specific version of this file always get loaded. Otherwise, Jetpack autoloader might decide
|
||||
// to load an older/newer version of the class (if, for example, both the standalone and bundled versions of the plugin
|
||||
// are installed, or in some other cases).
|
||||
namespace Automattic\Jetpack\Backup\V0005;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Wrappers for functions which throw an exception on errors and warnings instead of silently continuing operation.
|
||||
*
|
||||
* PHP is pretty lax with error reporting when doing I/O operations, e.g. a typical I/O helper function returns false,
|
||||
* null, and/or emits a warning, but the whole PHP application continues operation. It's for the caller of the I/O
|
||||
* helper function to test the returned result of the function, and notice + act upon I/O errors.
|
||||
*
|
||||
* We really want to know about each and every I/O error. Therefore, this class provides wrappers for some common
|
||||
* (mostly) I/O operations that throw an exception on errors instead of just returning false, null, or something else.
|
||||
* This wrapper class treats warnings as errors too.
|
||||
*
|
||||
* Given that static method names are similar to the ones used by PHP, they're prefixed with "t_" to not erroneously
|
||||
* trigger various security scanners.
|
||||
*/
|
||||
class Throw_On_Errors {
|
||||
|
||||
/**
|
||||
* Execute a callable, throw an exception (together with a descriptive label) on PHP warnings / errors.
|
||||
*
|
||||
* @param callable $callable Callable to execute.
|
||||
* @param string $label Label to add to the thrown exception to clarify what was attempted.
|
||||
*
|
||||
* @return mixed Callable's return value, if any.
|
||||
* @throws Exception On warnings thrown by the callable.
|
||||
* @noinspection PhpUnusedParameterInspection
|
||||
*/
|
||||
private static function throw_on_warnings( $callable, $label ) {
|
||||
$old_error_reporting = error_reporting( - 1 );
|
||||
$old_display_errors = ini_set( 'display_errors', 'stderr' );
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
|
||||
set_error_handler(
|
||||
/**
|
||||
* Temporary error handler.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.set-error-handler
|
||||
* @see https://www.php.net/manual/en/function.set-error-handler.php
|
||||
*
|
||||
* @param int $errno Level of the error raised.
|
||||
* @param string $errstr Error message.
|
||||
* @param string|null $errfile Filename that the error was raised in.
|
||||
* @param int|null $errline Line number where the error was raised.
|
||||
* @param array|null $errcontext Deprecated, unused.
|
||||
*
|
||||
* @return never
|
||||
* @throws Exception
|
||||
*/
|
||||
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
function ( $errno, $errstr, $errfile = null, $errline = null, $errcontext = null ) {
|
||||
throw new Exception( "$errstr (file: $errfile; line: $errline)" );
|
||||
}
|
||||
);
|
||||
|
||||
$result = null;
|
||||
$error_message = null;
|
||||
try {
|
||||
$result = $callable();
|
||||
} catch ( Throwable $throwable ) {
|
||||
$error_message = $throwable->getMessage();
|
||||
}
|
||||
|
||||
restore_error_handler();
|
||||
ini_set( 'display_errors', $old_display_errors );
|
||||
error_reporting( $old_error_reporting );
|
||||
|
||||
if ( $error_message !== null ) {
|
||||
throw new Exception( "$label failed: $error_message" );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return canonicalized absolute pathname, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.realpath
|
||||
* @see https://www.php.net/manual/en/function.realpath.php
|
||||
*
|
||||
* @param string $path Path being checked.
|
||||
*
|
||||
* @return string Canonicalized absolute pathname
|
||||
* @throws Exception On invalid parameters, or if realpath() has returned false or thrown warnings.
|
||||
*/
|
||||
public static function t_realpath( $path ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $path ) {
|
||||
throw new Exception( 'Filename for realpath() is unset' );
|
||||
}
|
||||
|
||||
$label = "realpath( '$path' )";
|
||||
|
||||
$realpath_result = static::throw_on_warnings(
|
||||
function () use ( $path ) {
|
||||
return realpath( $path );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $realpath_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
return $realpath_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a file or directory exists, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.file-exists
|
||||
* @see https://www.php.net/manual/en/function.file-exists.php
|
||||
*
|
||||
* @param string $filename Path to the file or directory.
|
||||
*
|
||||
* @return bool True if the file or directory specified by filename exists; false otherwise.
|
||||
* @throws Exception On invalid parameters, or if file_exists() has thrown warnings.
|
||||
*/
|
||||
public static function t_file_exists( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for file_exists() is unset' );
|
||||
}
|
||||
|
||||
return static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
return file_exists( $filename );
|
||||
},
|
||||
"file_exists( '$filename' )"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether the filename (or a directory) is readable, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.is-readable
|
||||
* @see https://www.php.net/manual/en/function.is-readable.php
|
||||
*
|
||||
* @param string $filename Filename (or directory) to check.
|
||||
*
|
||||
* @return bool True if the filename (or a directory) exists and is readable, false otherwise.
|
||||
* @throws Exception On invalid parameters, or if is_readable() has thrown warnings.
|
||||
*/
|
||||
public static function t_is_readable( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for is_readable() is unset' );
|
||||
}
|
||||
|
||||
return static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
return is_readable( $filename );
|
||||
},
|
||||
"is_readable( '$filename' )"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether the filename (or a directory) is writable, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.is-writable
|
||||
* @see https://www.php.net/manual/en/function.is-writable.php
|
||||
*
|
||||
* @param string $filename Filename (or directory) to check.
|
||||
*
|
||||
* @return bool True if the filename (or a directory) exists and is writable, false otherwise.
|
||||
* @throws Exception On invalid parameters, or if is_writable() has thrown warnings.
|
||||
*/
|
||||
public static function t_is_writable( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for is_writable() is unset' );
|
||||
}
|
||||
|
||||
return static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
|
||||
return is_writable( $filename );
|
||||
},
|
||||
"is_writable( '$filename' )"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.filesize
|
||||
* @see https://www.php.net/manual/en/function.filesize.php
|
||||
*
|
||||
* @param string $filename Path to the file.
|
||||
*
|
||||
* @return int Size of the file in bytes
|
||||
* @throws Exception On invalid parameters, or if filesize() has thrown warnings.
|
||||
*/
|
||||
public static function t_filesize( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for filesize() is unset' );
|
||||
}
|
||||
|
||||
$label = "filesize( '$filename' )";
|
||||
|
||||
$filesize_result = static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
return filesize( $filename );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $filesize_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
return $filesize_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file modification time, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.filemtime
|
||||
* @see https://www.php.net/manual/en/function.filemtime.php
|
||||
*
|
||||
* @param string $filename Path to the file.
|
||||
*
|
||||
* @return int The time the file was last modified
|
||||
* @throws Exception On invalid parameters, or if filemtime() has thrown warnings.
|
||||
*/
|
||||
public static function t_filemtime( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for filemtime() is unset' );
|
||||
}
|
||||
|
||||
$label = "filemtime( '$filename' )";
|
||||
|
||||
$filemtime_result = static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
return filemtime( $filename );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $filemtime_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
return $filemtime_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether the filename is a directory (follow symlinks), throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.is-dir
|
||||
* @see https://www.php.net/manual/en/function.is-dir.php
|
||||
*
|
||||
* @param string $filename Path to the file.
|
||||
*
|
||||
* @return bool True if the filename (or the symlink's target) exists and is a directory, false otherwise.
|
||||
* @throws Exception On invalid parameters, if is_dir() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_is_dir( $filename ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for is_dir() is unset' );
|
||||
}
|
||||
|
||||
return static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
return is_dir( $filename );
|
||||
},
|
||||
"is_dir( '$filename' )"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a directory, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.mkdir
|
||||
* @see https://www.php.net/manual/en/function.mkdir.php
|
||||
*
|
||||
* @param string $directory Directory path.
|
||||
* @param int $permissions Permissions of the newly created directory.
|
||||
* @param bool $recursive If true, then any parent directories to the directory specified will also be created,
|
||||
* with the same permissions.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception On invalid parameters, if mkdir() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_mkdir( $directory, $permissions = 0777, $recursive = false ) {
|
||||
// PHP 5.x won't complain about permissions being null, so let's do it ourselves.
|
||||
if ( $permissions === null ) {
|
||||
throw new Exception( 'Permissions for mkdir() are unset' );
|
||||
}
|
||||
|
||||
$label = "mkdir( '$directory', 0" . decoct( $permissions ) . ', ' . ( $recursive ? 'true' : 'false' ) . ' )';
|
||||
|
||||
$mkdir_result = static::throw_on_warnings(
|
||||
function () use ( $directory, $permissions, $recursive ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
|
||||
return mkdir( $directory, $permissions, $recursive );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $mkdir_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files and directories inside the specified path, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.scandir
|
||||
* @see https://www.php.net/manual/en/function.scandir.php
|
||||
*
|
||||
* @param string $directory Directory that will be scanned.
|
||||
*
|
||||
* @return string[] An array of filenames.
|
||||
* @throws Exception If scandir() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_scandir( $directory ) {
|
||||
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $directory ) {
|
||||
throw new Exception( 'Directory for scandir() is unset' );
|
||||
}
|
||||
|
||||
$label = "scandir( '$directory' )";
|
||||
|
||||
$scandir_result = static::throw_on_warnings(
|
||||
function () use ( $directory ) {
|
||||
return scandir( $directory );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $scandir_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
return $scandir_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directory, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.rmdir
|
||||
* @see https://www.php.net/manual/en/function.rmdir.php
|
||||
*
|
||||
* @param string $directory Directory path.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception On invalid parameters, if rmdir() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_rmdir( $directory ) {
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $directory ) {
|
||||
throw new Exception( 'Directory for mkdir() is unset' );
|
||||
}
|
||||
|
||||
$label = "rmdir( '$directory' )";
|
||||
|
||||
$rmdir_result = static::throw_on_warnings(
|
||||
function () use ( $directory ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
|
||||
return rmdir( $directory );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $rmdir_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.unlink
|
||||
* @see https://www.php.net/manual/en/function.unlink.php
|
||||
*
|
||||
* @param string $filename Path to the file.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception If unlink() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_unlink( $filename ) {
|
||||
|
||||
$label = "unlink( '$filename' )";
|
||||
|
||||
$unlink_result = static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
|
||||
return unlink( $filename );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $unlink_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a file, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.file-put-contents
|
||||
* @see https://www.php.net/manual/en/function.file-put-contents.php
|
||||
*
|
||||
* @param string $filename Path to the file where to write the data.
|
||||
* @param string $data The data to write.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception If file_put_contents() has thrown warnings, has failed, or if it didn't write all the bytes.
|
||||
*/
|
||||
public static function t_file_put_contents( $filename, $data ) {
|
||||
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for f_p_c() is unset' );
|
||||
}
|
||||
if ( $data === null ) {
|
||||
throw new Exception( 'Data to write is null' );
|
||||
}
|
||||
|
||||
$data_length = strlen( $data );
|
||||
|
||||
// Weird label is intentional, otherwise security scanners find this label suspicious.
|
||||
$label = "f_p_c( '$filename', $data_length bytes of data )";
|
||||
|
||||
$number_of_bytes_written = static::throw_on_warnings(
|
||||
function () use ( $filename, $data ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
|
||||
return file_put_contents( $filename, $data );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $number_of_bytes_written ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
if ( $number_of_bytes_written !== $data_length ) {
|
||||
throw new Exception(
|
||||
"$label was expected to write $data_length bytes, but wrote $number_of_bytes_written bytes"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read entire file into a string, throw on warnings / errors.
|
||||
*
|
||||
* @see https://php-legacy-docs.zend.com/manual/php5/en/function.file-get-contents
|
||||
* @see https://www.php.net/manual/en/function.file-get-contents.php
|
||||
*
|
||||
* @param string $filename Name of the file to read.
|
||||
*
|
||||
* @return string The read data.
|
||||
* @throws Exception If file_get_contents() has thrown warnings, or has failed.
|
||||
*/
|
||||
public static function t_file_get_contents( $filename ) {
|
||||
|
||||
// PHP 5.x won't complain about parameter being unset, so let's do it ourselves.
|
||||
if ( ! $filename ) {
|
||||
throw new Exception( 'Filename for f_g_c() is unset' );
|
||||
}
|
||||
|
||||
// Weird label is intentional, otherwise security scanners find this label suspicious.
|
||||
$label = "f_g_c( '$filename' )";
|
||||
|
||||
$fgc_result = static::throw_on_warnings(
|
||||
function () use ( $filename ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
return file_get_contents( $filename );
|
||||
},
|
||||
$label
|
||||
);
|
||||
|
||||
if ( false === $fgc_result ) {
|
||||
throw new Exception( "Unable to $label" );
|
||||
}
|
||||
|
||||
return $fgc_result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Action Hooks for Jetpack Backup module.
|
||||
*
|
||||
* @package automattic/jetpack-backup
|
||||
*/
|
||||
|
||||
// If WordPress's plugin API is available already, use it. If not,
|
||||
// drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
|
||||
if ( function_exists( 'add_filter' ) ) {
|
||||
$add_filter = 'add_filter';
|
||||
$add_action = 'add_action';
|
||||
} else {
|
||||
$add_filter = function ( $name, $cb, $priority = 10, $accepted_args = 1 ) {
|
||||
global $wp_filter;
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_filter[ $name ][ $priority ][] = array(
|
||||
'accepted_args' => $accepted_args,
|
||||
'function' => $cb,
|
||||
);
|
||||
};
|
||||
$add_action = $add_filter;
|
||||
}
|
||||
|
||||
// Clean up expired Helper Scripts from a scheduled event.
|
||||
$add_action( 'jetpack_backup_cleanup_helper_scripts', array( 'Automattic\\Jetpack\\Backup\\V0005\\Helper_Script_Manager', 'cleanup_expired_helper_scripts' ) );
|
||||
|
||||
// Register REST routes.
|
||||
$add_action( 'rest_api_init', array( 'Automattic\\Jetpack\\Backup\\V0005\\REST_Controller', 'register_rest_routes' ) );
|
||||
|
||||
// Set up package version hook.
|
||||
$add_filter( 'jetpack_package_versions', 'Automattic\\Jetpack\\Backup\\Package_Version::send_package_version_to_tracker' );
|
||||
|
||||
// Register Jetpack Backup abilities with the WordPress Abilities API at autoload
|
||||
// time so the surface is available in any consumer that loads this package
|
||||
// (both the standalone Jetpack Backup plugin and the Jetpack plugin). The
|
||||
// `jetpack_wp_abilities_enabled` filter (default false) gates registration,
|
||||
// so this is a no-op until a site opts in.
|
||||
$add_action(
|
||||
'plugins_loaded',
|
||||
static function () {
|
||||
if ( ! apply_filters( 'jetpack_wp_abilities_enabled', false ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! class_exists( \Automattic\Jetpack\Backup\V0005\Abilities\Backup_Abilities::class ) ) {
|
||||
return;
|
||||
}
|
||||
\Automattic\Jetpack\Backup\V0005\Abilities\Backup_Abilities::init();
|
||||
},
|
||||
20
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* Main entry point for auto-generated asset registration.
|
||||
* Do not edit this file manually.
|
||||
*
|
||||
* @package jetpack_backup
|
||||
*/
|
||||
|
||||
// Load script module registration.
|
||||
$modules_file = __DIR__ . '/modules.php';
|
||||
if ( file_exists( $modules_file ) ) {
|
||||
require_once $modules_file;
|
||||
}
|
||||
|
||||
// Load script registration.
|
||||
$scripts_file = __DIR__ . '/scripts.php';
|
||||
if ( file_exists( $scripts_file ) ) {
|
||||
require_once $scripts_file;
|
||||
}
|
||||
|
||||
// Load style registration.
|
||||
$styles_file = __DIR__ . '/styles.php';
|
||||
if ( file_exists( $styles_file ) ) {
|
||||
require_once $styles_file;
|
||||
}
|
||||
|
||||
// Load routes registration.
|
||||
$routes_file = __DIR__ . '/routes.php';
|
||||
if ( file_exists( $routes_file ) ) {
|
||||
require_once $routes_file;
|
||||
}
|
||||
|
||||
// Load widgets registration.
|
||||
$widgets_file = __DIR__ . '/widgets.php';
|
||||
if ( file_exists( $widgets_file ) ) {
|
||||
require_once $widgets_file;
|
||||
}
|
||||
|
||||
// Load pages registration.
|
||||
$pages_file = __DIR__ . '/pages.php';
|
||||
if ( file_exists( $pages_file ) ) {
|
||||
require_once $pages_file;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin constants - Auto-generated by build process.
|
||||
* Do not edit this file manually.
|
||||
*
|
||||
* Returns an array of constants for use in other templates.
|
||||
*
|
||||
* @package jetpack_backup
|
||||
*/
|
||||
|
||||
return array(
|
||||
'version' => 'undefined',
|
||||
'build_url' => plugin_dir_url( __FILE__ ),
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" width="32" height="32" rx="4" fill="#008710"/>
|
||||
<mask id="mask0_3026_4949" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="9" y="6" width="15" height="20">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5002 13.5002V11.0002C13.7418 11.0002 11.5002 13.2418 11.5002 16.0002C11.5002 16.8418 11.7085 17.6418 12.0835 18.3335L10.8668 19.5502C10.2168 18.5252 9.8335 17.3085 9.8335 16.0002C9.8335 12.3168 12.8168 9.3335 16.5002 9.3335V6.8335L19.8335 10.1668L16.5002 13.5002ZM20.9168 13.6668L22.1335 12.4502C22.7835 13.4752 23.1668 14.6918 23.1668 16.0002C23.1668 19.6835 20.1835 22.6668 16.5002 22.6668V25.1668L13.1668 21.8335L16.5002 18.5002V21.0002C19.2585 21.0002 21.5002 18.7585 21.5002 16.0002C21.5002 15.1585 21.2835 14.3668 20.9168 13.6668Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_3026_4949)">
|
||||
<rect x="6.5" y="6" width="20" height="20" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 992 B |
@@ -0,0 +1,33 @@
|
||||
<svg
|
||||
class="backup__animation-el-1"
|
||||
width="176"
|
||||
height="212"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter1_d)">
|
||||
<rect x="40" y="40" width="96" height="132" rx="3" fill="#98C6D9"></rect>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter1_d"
|
||||
x="0"
|
||||
y="0"
|
||||
width="176"
|
||||
height="212"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
></feColorMatrix>
|
||||
<feOffset></feOffset>
|
||||
<feGaussianBlur stdDeviation="20"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"></feColorMatrix>
|
||||
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow"></feBlend>
|
||||
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape"></feBlend>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 922 B |
@@ -0,0 +1,33 @@
|
||||
<svg
|
||||
class="backup__animation-el-2"
|
||||
width="248"
|
||||
height="200"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter2_d)">
|
||||
<rect x="40" y="40" width="168" height="120" rx="3" fill="#F2D76B"></rect>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter2_d"
|
||||
x="0"
|
||||
y="0"
|
||||
width="248"
|
||||
height="200"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
></feColorMatrix>
|
||||
<feOffset></feOffset>
|
||||
<feGaussianBlur stdDeviation="20"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"></feColorMatrix>
|
||||
<feBlend in2="BackgroundImageFix" result="effect2_dropShadow"></feBlend>
|
||||
<feBlend in="SourceGraphic" in2="effect2_dropShadow" result="shape"></feBlend>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
@@ -0,0 +1,42 @@
|
||||
<svg
|
||||
class="backup__animation-el-3"
|
||||
width="536"
|
||||
height="196"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter3_d)">
|
||||
<rect x="40" y="40" width="456" height="116" rx="8" fill="#fff"></rect>
|
||||
</g>
|
||||
<path
|
||||
d="M475.35 62.04A7.49 7.49 0 00468 56c-2.89 0-5.4 1.64-6.65 4.04A5.994 5.994 0 00456 66c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z"
|
||||
fill="#E9EFF5"
|
||||
></path>
|
||||
<circle cx="100" cy="98" r="36" fill="#F7A8C3"></circle>
|
||||
<path
|
||||
d="M160 84a6 6 0 016-6h174a6 6 0 110 12H166a6 6 0 01-6-6zM160 112a6 6 0 016-6h276a6 6 0 110 12H166a6 6 0 01-6-6z"
|
||||
fill="#E9EFF5"
|
||||
></path>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter3_d"
|
||||
x="0"
|
||||
y="0"
|
||||
width="536"
|
||||
height="196"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
></feColorMatrix>
|
||||
<feOffset></feOffset>
|
||||
<feGaussianBlur stdDeviation="20"></feGaussianBlur>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"></feColorMatrix>
|
||||
<feBlend in2="BackgroundImageFix" result="effect3_dropShadow"></feBlend>
|
||||
<feBlend in="SourceGraphic" in2="effect3_dropShadow" result="shape"></feBlend>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" maskUnits="userSpaceOnUse" x="0" y="5" width="32" height="22"><path fill-rule="evenodd" clip-rule="evenodd" d="M16 5.333c4.853 0 8.893 3.453 9.8 8.053 3.467.24 6.2 3.094 6.2 6.614a6.67 6.67 0 01-6.667 6.666H8c-4.413 0-8-3.586-8-8 0-4.12 3.12-7.52 7.133-7.946A9.994 9.994 0 0116 5.333zM8.667 18l4.666 4.666 8.787-8.786L20.24 12l-6.907 6.906-2.786-2.786L8.667 18z" fill="#fff"></path></mask><g mask="url(#a)"><path fill="#069E08" d="M0 0h32v32H0z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 551 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="32" height="32" viewBox="0 0 24 24"><path fill="#d63639" d="M19 20H6C2.7 20 0 17.3 0 14C0 10.9 2.3 8.4 5.3 8C6.6 5.6 9.1 4 12 4C15.6 4 18.7 6.6 19.4 10C22 10.2 24 12.3 24 15C24 17.7 21.7 20 19 20M11 15V17H13V15H11M11 13H13V7H11V13Z" /></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="4" fill="#008710"/>
|
||||
<mask id="mask0_3026_4944" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="6" y="9" width="20" height="14">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.125 14.3668C21.5583 11.4918 19.0333 9.3335 16 9.3335C13.5917 9.3335 11.5 10.7002 10.4583 12.7002C7.95 12.9668 6 15.0918 6 17.6668C6 20.4252 8.24167 22.6668 11 22.6668H21.8333C24.1333 22.6668 26 20.8002 26 18.5002C26 16.3002 24.2917 14.5168 22.125 14.3668ZM21.8333 21.0002H11C9.15833 21.0002 7.66667 19.5085 7.66667 17.6668C7.66667 15.9585 8.94167 14.5335 10.6333 14.3585L11.525 14.2668L11.9417 13.4752C12.7333 11.9502 14.2833 11.0002 16 11.0002C18.1833 11.0002 20.0667 12.5502 20.4917 14.6918L20.7417 15.9418L22.0167 16.0335C23.3167 16.1168 24.3333 17.2085 24.3333 18.5002C24.3333 19.8752 23.2083 21.0002 21.8333 21.0002ZM12.5917 16.0752L14.3333 17.8168L18.1667 13.9835L19.3417 15.1585L14.3333 20.1668L11.4167 17.2502L12.5917 16.0752Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_3026_4944)">
|
||||
<rect x="6" y="6" width="20" height="20" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg width="380" height="183" viewBox="0 0 380 183" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.75" filter="url(#filter0_f_5953_11924)">
|
||||
<path d="M360 -60C360 7.93102 304.931 63 237 63C169.069 63 114 7.93102 114 -60C114 -127.931 169.069 -183 237 -183C304.931 -183 360 -127.931 360 -60Z" fill="url(#paint0_linear_5953_11924)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_5953_11924" x="-6" y="-303" width="486" height="486" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_5953_11924"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5953_11924" x1="360" y1="-60" x2="114" y2="-60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#48FF50"/>
|
||||
<stop offset="0.471846" stop-color="#108642"/>
|
||||
<stop offset="1" stop-color="#2E38FA"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 990 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" width="32" height="32" rx="4" fill="#008710"/>
|
||||
<mask id="mask0_3026_4960" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="8" y="7" width="17" height="18">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4998 7.6665C11.8998 7.6665 8.1665 11.3998 8.1665 15.9998C8.1665 20.5998 11.8998 24.3332 16.4998 24.3332C21.0998 24.3332 24.8332 20.5998 24.8332 15.9998C24.8332 11.3998 21.0998 7.6665 16.4998 7.6665ZM16.4998 22.6665C12.8248 22.6665 9.83317 19.6748 9.83317 15.9998C9.83317 12.3248 12.8248 9.33317 16.4998 9.33317C20.1748 9.33317 23.1665 12.3248 23.1665 15.9998C23.1665 19.6748 20.1748 22.6665 16.4998 22.6665ZM11.4998 17.6665C11.4998 16.746 12.246 15.9998 13.1665 15.9998C14.087 15.9998 14.8332 16.746 14.8332 17.6665C14.8332 18.587 14.087 19.3332 13.1665 19.3332C12.246 19.3332 11.4998 18.587 11.4998 17.6665ZM16.4998 10.9998C15.5794 10.9998 14.8332 11.746 14.8332 12.6665C14.8332 13.587 15.5794 14.3332 16.4998 14.3332C17.4203 14.3332 18.1665 13.587 18.1665 12.6665C18.1665 11.746 17.4203 10.9998 16.4998 10.9998ZM18.1665 17.6665C18.1665 16.746 18.9127 15.9998 19.8332 15.9998C20.7536 15.9998 21.4998 16.746 21.4998 17.6665C21.4998 18.587 20.7536 19.3332 19.8332 19.3332C18.9127 19.3332 18.1665 18.587 18.1665 17.6665Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_3026_4960)">
|
||||
<rect x="6.5" y="6" width="20" height="20" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="4" fill="#008710"/>
|
||||
<mask id="mask0_3026_4955" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="6" y="8" width="18" height="16">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.75 16C8.75 11.8583 12.1083 8.5 16.25 8.5C20.3917 8.5 23.75 11.8583 23.75 16C23.75 20.1417 20.3917 23.5 16.25 23.5C14.175 23.5 12.3083 22.6583 10.95 21.3L12.1333 20.1167C13.1833 21.175 14.6417 21.8333 16.25 21.8333C19.475 21.8333 22.0833 19.225 22.0833 16C22.0833 12.775 19.475 10.1667 16.25 10.1667C13.025 10.1667 10.4167 12.775 10.4167 16H12.9167L9.55 19.3583L9.49167 19.2417L6.25 16H8.75ZM15.4167 16.8333V12.6667H16.6667V16.125L19.6 17.8667L18.9583 18.9333L15.4167 16.8333Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_3026_4955)">
|
||||
<rect x="5" y="6" width="20" height="20" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4.75" y="7.75" width="14.5" height="10.5" rx="1.25" stroke="#069E08" stroke-width="1.5"></rect><path fill="#069E08" d="M6 5h5v3H6zM13 5h5v3h-5z"></path></svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 7L8 13.5c-.167-1.167 1-5.167 1.5-6 .365-.608 1-1.5 2.5-2.5s5-2 5-2c0 1-1 2.5-2.5 4z" fill="#069E08"></path><path d="M7 16l1-2.5m0 0L14.5 7C16 5.5 17 4 17 3c0 0-3.5 1-5 2S9.865 6.892 9.5 7.5c-.5.833-1.667 4.833-1.5 6z" stroke="#069E08" stroke-width="1.5" stroke-linecap="round"></path><path d="M17.743 3.1a.75.75 0 10-1.486-.2l1.486.2zm-1.486-.2c-.089.662-.131 1.443-.17 2.235-.04.804-.075 1.63-.146 2.423-.072.795-.176 1.525-.344 2.134-.17.62-.386 1.036-.627 1.278l1.06 1.06c.509-.508.814-1.216 1.013-1.94.203-.735.317-1.568.392-2.398.074-.831.111-1.694.15-2.483.04-.802.08-1.52.158-2.11l-1.486-.198zm-1.287 8.07c-.276.275-.767.574-1.435.866-.653.287-1.415.541-2.175.754-.758.213-1.502.38-2.114.493-.63.117-1.071.167-1.246.167v1.5c.325 0 .885-.075 1.52-.192a26.28 26.28 0 002.245-.523 18.402 18.402 0 002.372-.825c.722-.316 1.419-.705 1.893-1.18l-1.06-1.06z" fill="#069E08"></path><path stroke="#069E08" stroke-width="1.5" d="M6 19.25h8"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.44 13.905a5.345 5.345 0 01-10.69 0c0-.652.307-1.557.866-2.619.547-1.039 1.284-2.137 2.034-3.15a47.06 47.06 0 012.445-3.014 48.194 48.194 0 012.445 3.015c.75 1.012 1.488 2.11 2.034 3.15.56 1.061.866 1.966.866 2.618z" stroke="#069E08" stroke-width="1.5"></path></svg>
|
||||
|
After Width: | Height: | Size: 353 B |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M19.293 13.758a7.503 7.503 0 01-14.168 1.243l3.67-2.177 3.456 1.217a.75.75 0 00.686-.098l2.887-2.073.08.04c.296.145.703.349 1.15.58.78.406 1.64.877 2.239 1.268zm.206-1.635a37.63 37.63 0 00-1.754-.964 63.44 63.44 0 00-1.538-.771l-.099-.048-.026-.012-.01-.005a.75.75 0 00-.76.068l-2.932 2.105-3.417-1.203a.75.75 0 00-.631.062l-3.675 2.18A7.5 7.5 0 1119.5 12.123zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" fill="#069E08"></path></svg>
|
||||
|
After Width: | Height: | Size: 548 B |
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 4C16.42 4 20 7.58 20 12C20 16.42 16.42 20 12 20C7.58 20 4 16.42 4 12C4 7.58 7.58 4 12 4ZM13.13 13.38L13.48 6.92H10.52L10.87 13.38H13.13ZM13.04 16.74C13.28 16.51 13.41 16.19 13.41 15.78C13.41 15.36 13.29 15.04 13.05 14.81C12.81 14.58 12.46 14.46 11.99 14.46C11.52 14.46 11.17 14.58 10.92 14.81C10.67 15.04 10.55 15.36 10.55 15.78C10.55 16.19 10.68 16.51 10.93 16.74C11.19 16.97 11.54 17.08 11.99 17.08C12.44 17.08 12.79 16.97 13.04 16.74V16.74Z" fill="#D63638"/></svg>
|
||||
|
After Width: | Height: | Size: 556 B |
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('jetpack-connection', 'jetpack-script-data', 'moment', 'react', 'react-jsx-runtime', 'wp-a11y', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n', 'wp-polyfill', 'wp-primitives', 'wp-url'), 'version' => '7d9184e6ce035d060c7d');
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* Script module registration - Auto-generated by build process.
|
||||
* Do not edit this file manually.
|
||||
*
|
||||
* @package jetpack_backup
|
||||
*/
|
||||
|
||||
/**
|
||||
* Register all script modules.
|
||||
*/
|
||||
function jetpack_backup_register_script_modules() {
|
||||
// Ensure this only runs once. wp_default_scripts can fire multiple times,
|
||||
// and each wp_deregister_script_module() call also dequeues the module.
|
||||
// If a module was enqueued between calls, repeated deregister/register
|
||||
// cycles would lose the enqueue state.
|
||||
static $already_registered = false;
|
||||
if ( $already_registered ) {
|
||||
return;
|
||||
}
|
||||
$already_registered = true;
|
||||
|
||||
// Load build constants
|
||||
$constants_file = __DIR__ . '/constants.php';
|
||||
if ( ! file_exists( $constants_file ) ) {
|
||||
return;
|
||||
}
|
||||
$build_constants = require $constants_file;
|
||||
$modules_dir = __DIR__ . '/modules';
|
||||
$modules_file = $modules_dir . '/registry.php';
|
||||
|
||||
if ( ! file_exists( $modules_file ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$modules = require $modules_file;
|
||||
$base_url = $build_constants['build_url'] . 'modules/';
|
||||
|
||||
foreach ( $modules as $module ) {
|
||||
// WASM-only modules (e.g., vips worker) only have .min.js; use it even when SCRIPT_DEBUG is `true`.
|
||||
$extension = '.min.js';
|
||||
|
||||
$asset_path = $modules_dir . '/' . $module['asset'];
|
||||
$asset = file_exists( $asset_path ) ? require $asset_path : array();
|
||||
|
||||
// Deregister first to override any previously registered version
|
||||
// (e.g., Core's default modules when running as a plugin).
|
||||
wp_deregister_script_module( $module['id'] );
|
||||
|
||||
wp_register_script_module(
|
||||
$module['id'],
|
||||
$base_url . $module['path'] . $extension,
|
||||
$asset['module_dependencies'] ?? array(),
|
||||
$asset['version'] ?? false,
|
||||
array(
|
||||
'fetchpriority' => 'low',
|
||||
'in_footer' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
add_action( 'wp_default_scripts', 'jetpack_backup_register_script_modules' );
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-html-entities', 'wp-i18n', 'wp-keyboard-shortcuts', 'wp-keycodes', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/lazy-editor', 'import' => 'dynamic'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '188faf6c64fdd1b4');
|
||||