$this->name = $name; } $this->lastModified = time(); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function getIterator() { return new \ArrayIterator($this->settingValues); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { return [ 'settings' => $this->settingValues, 'lastModified' => $this->lastModified, 'adminThemeMetadata' => $this->adminThemeMetadata, ]; } /** * @param \WP_Post $post * @return Option */ public static function fromPost(\WP_Post $post) { return static::fromJson($post->post_content)->each( function ($changeset) use ($post) { $changeset->postId = $post->ID; $changeset->name = $post->post_name; } ); } /** * @param string $jsonString * @return Option */ public static function fromJson($jsonString) { $data = json_decode($jsonString, true); if ( !is_array($data) ) { return None::getInstance(); } return Option::fromValue(static::fromArray($data)); } /** * @param $data * @return static */ public static function fromArray($data) { $changeset = new static(); $changeset->settingValues = $data['settings']; $changeset->lastModified = isset($data['lastModified']) ? intval($data['lastModified']) : null; if ( isset($data['adminThemeMetadata']) ) { $changeset->adminThemeMetadata = AcAdminThemeMetadata::parseArray($data['adminThemeMetadata']); } return $changeset; } /** * @param null|int $postId * @return AcChangeset */ public function setPostId($postId) { if ( $this->postId !== null ) { throw new \RuntimeException('Cannot change the post ID of an existing changeset.'); } $this->postId = $postId; return $this; } /** * @return null|int */ public function getPostId() { return $this->postId; } /** * @return string|null */ public function getName() { return $this->name; } public function setSettingValue($settingId, $value) { $this->settingValues[$settingId] = $value; } /** * @param string $settingId * @return mixed|null */ public function getSettingValue($settingId) { if ( array_key_exists($settingId, $this->settingValues) ) { return $this->settingValues[$settingId]; } return null; } /** * @param string $settingId * @return bool */ public function hasValueFor($settingId) { return array_key_exists($settingId, $this->settingValues); } public static function isSupportedStatus($status) { return in_array($status, [ 'draft', 'auto-draft', 'publish', 'future', 'trash', 'private', 'pending', ]); } /** * @return string|null */ public function getStatus() { if ( !empty($this->postId) ) { $status = get_post_status($this->postId); if ( $status ) { return $status; } } return $this->status; } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function count() { return count($this->settingValues); } /** * @return int|null */ public function getLastModified() { return $this->lastModified; } public function setLastModifiedToNow() { $this->lastModified = time(); } /** * @param AcAdminThemeMetadata|null $metadata * @return void */ public function setAdminThemeMetadata($metadata) { $this->adminThemeMetadata = $metadata; } /** * @return \YahnisElsts\AdminMenuEditor\AdminCustomizer\AcAdminThemeMetadata|null */ public function getAdminThemeMetadata() { return $this->adminThemeMetadata; } /** * Create a new changeset with the same settings as this one. * * Does NOT save the new changeset. * * @param string|null $name * @return static */ public function duplicate($name = null) { $newChangeset = new static($name); $newChangeset->settingValues = $this->settingValues; $newChangeset->adminThemeMetadata = $this->adminThemeMetadata; $newChangeset->lastModified = $this->lastModified; return $newChangeset; } } class AcValidationState implements \JsonSerializable { /** * @var bool */ protected $isValid = true; /** * @var \WP_Error[] */ protected $errors = []; public function addError(\WP_Error $error, $markAsInvalid = true) { $this->errors[] = $error; if ( $markAsInvalid ) { $this->isValid = false; } } public function setValid($valid) { $this->isValid = $valid; } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { //Convert WP_Error instances to JSON-compatible associative arrays. $serializableErrors = []; foreach ($this->errors as $error) { foreach ($error->errors as $code => $messages) { foreach ($messages as $message) { $serializableErrors[] = [ 'code' => $code, 'message' => $message, ]; } } } return [ 'isValid' => $this->isValid, 'errors' => $serializableErrors, ]; } /** * @return bool */ public function isValid() { return $this->isValid; } } class AcSaveResponse implements \JsonSerializable { private $fields = []; private $notes = []; /** * @var AcChangeset|null */ private $changeset = null; /** * @param array $additionalFields * @return $this */ public function mergeWith($additionalFields) { $this->fields = array_merge($this->fields, $additionalFields); return $this; } public function error($message, $code = null) { $this->fields['message'] = $message; if ( $code !== null ) { $this->fields['code'] = $code; } return $this; } /** * @param array $validationResults * @return $this */ public function setValidationResults($validationResults) { $this->fields['validationResults'] = $validationResults; return $this; } public function setChangeset(AcChangeset $changeset) { $this->changeset = $changeset; } /** * @param string $message * @return $this */ public function addNote($message) { $this->notes[] = $message; return $this; } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { $result = $this->fields; if ( $this->changeset !== null ) { $result = array_merge($result, [ 'changeset' => $this->changeset->getName(), 'changesetItemCount' => count($this->changeset), 'changesetStatus' => $this->changeset->getStatus(), ]); } if ( !empty($this->notes) ) { $result['notes'] = $this->notes; } return $result; } } class AcAdminThemeMetadata implements \JsonSerializable { const MAX_FIELD_LENGTH = 1024; const MIN_ID_PREFIX_LENGTH = 10; const DESIRED_ID_PREFIX_LENGTH = 16; const ID_PREFIX_HASH_LENGTH = 6; public $pluginName = 'Custom Admin Theme'; public $pluginSlug = ''; public $pluginVersion = '1.0'; public $pluginUrl = ''; public $authorName = ''; public $authorUrl = ''; public $requiredWpVersion = '4.7'; //Current required version for AME itself. public $testedWpVersion = '6.2'; public $shortDescription = ' '; public $identifierPrefix = ''; /** * @var bool Whether the user has ever confirmed the metadata settings * (e.g. by clicking "OK" or "Download" in the metadata editor). */ public $wasEverConfirmed = false; /** * @param string $jsonString * @return self */ public static function parseJson($jsonString) { $parsed = json_decode($jsonString, true); if ( $parsed === null ) { throw new \InvalidArgumentException('Invalid JSON string'); } if ( !is_array($parsed) ) { throw new \InvalidArgumentException('JSON string does not represent an object'); } return self::parseArray($parsed); } /** * @param array $inputArray * @return self */ public static function parseArray($inputArray) { $result = new self(); //Tested WP version defaults to the current version. $result->testedWpVersion = get_bloginfo('version'); if ( !empty($result->testedWpVersion) ) { //Keep only the major and minor version numbers. $result->testedWpVersion = preg_replace('/^(\d+\.\d+).*$/i', '$1', $result->testedWpVersion); } $parsers = [ 'pluginName' => [__CLASS__, 'parsePluginName'], 'pluginSlug' => [__CLASS__, 'parsePluginSlug'], 'requiredWpVersion' => [__CLASS__, 'parseVersionNumber'], 'testedWpVersion' => [__CLASS__, 'parseVersionNumber'], 'pluginVersion' => [__CLASS__, 'parseVersionNumber'], 'authorName' => [__CLASS__, 'parseAuthorName'], 'authorUrl' => 'esc_url_raw', 'pluginUrl' => 'esc_url_raw', 'shortDescription' => [__CLASS__, 'parseShortDescription'], 'identifierPrefix' => [__CLASS__, 'parseIdentifierPrefix'], 'wasEverConfirmed' => 'boolval', ]; foreach ($parsers as $key => $parser) { if ( isset($inputArray[$key]) ) { if ( is_string($inputArray[$key]) && (strlen($inputArray[$key]) > self::MAX_FIELD_LENGTH) ) { throw new \InvalidArgumentException(sprintf( 'Field "%s" is too long (max %d characters)', $key, self::MAX_FIELD_LENGTH )); } $result->$key = is_callable($parser) ? call_user_func($parser, $inputArray[$key]) : $inputArray[$key]; } } //Fallback for the prefix. if ( empty($result->identifierPrefix) ) { $result->identifierPrefix = self::generateIdentifierPrefix($result); } else if ( strlen($result->identifierPrefix) < self::MIN_ID_PREFIX_LENGTH ) { //Prefix should be long enough to be unique. $result->identifierPrefix = ( $result->identifierPrefix . self::generateHashForPrefix( $result, self::MIN_ID_PREFIX_LENGTH - strlen($result->identifierPrefix) ) ); } //Plugin version defaults to 1.0. if ( empty($result->pluginVersion) ) { $result->pluginVersion = '1.0'; } return $result; } public static function parseVersionNumber($input) { if ( !is_string($input) || empty($input) ) { return null; } if ( preg_match('/^(\d{1,3}+)((?:\.\d{1,5}+){0,2})/i', trim($input), $matches) ) { $parts = array_slice($matches, 1, 3); return implode('', $parts); } else { throw new \InvalidArgumentException('Invalid version number'); } } private static function parsePluginName($name) { if ( !is_string($name) || empty($name) ) { throw new \InvalidArgumentException('Invalid plugin name'); } //Strip out any HTML tags. $name = wp_strip_all_tags($name); //Limit the length to 100 characters. return substr($name, 0, 100); } private static function parseShortDescription($description) { if ( !is_string($description) ) { throw new \InvalidArgumentException('Invalid short description'); } $description = sanitize_text_field($description); return substr($description, 0, 500); } private static function parseAuthorName($name) { if ( !is_string($name) ) { throw new \InvalidArgumentException('Invalid author name'); } $name = sanitize_text_field($name); return substr($name, 0, 100); } private static function parseIdentifierPrefix($prefix) { if ( !is_string($prefix) ) { throw new \InvalidArgumentException('Invalid identifier prefix'); } $prefix = self::sanitizeIdPrefix($prefix); return substr($prefix, 0, 20); } protected static function generateIdentifierPrefix(AcAdminThemeMetadata $meta) { $prefix = sanitize_title($meta->pluginName); $prefix = str_replace('-', ' ', $prefix); $prefix = ucwords($prefix); $prefix = self::sanitizeIdPrefix($prefix); //Prefix should always start with a letter. if ( !preg_match('/^[a-z]/i', $prefix) ) { $prefix = 'At' . $prefix; //At = Admin theme } //Truncate the prefix so that there's space for a hash. $prefix = substr($prefix, 0, max(self::DESIRED_ID_PREFIX_LENGTH - self::ID_PREFIX_HASH_LENGTH, 1)); //Add a hash to make the prefix more likely to be unique. $prefix .= self::generateHashForPrefix( $meta, max(self::ID_PREFIX_HASH_LENGTH, self::DESIRED_ID_PREFIX_LENGTH - strlen($prefix)) ); return $prefix; } protected static function generateHashForPrefix(AcAdminThemeMetadata $meta, $length) { $hash = sha1($meta->pluginName . '|' . $meta->authorName . '|' . wp_rand()); return substr($hash, 0, $length); } private static function sanitizeIdPrefix($prefix) { //Allow alphanumeric characters and underscores. No dashes since the prefix //is also used in class names. return preg_replace('/[^a-zA-Z0-9_]/', '', $prefix); } public function getEffectivePluginSlug() { if ( !empty($this->pluginSlug) ) { return $this->pluginSlug; } else { //Fallback to the plugin name converted to a slug. return self::parsePluginSlug($this->pluginName); } } private static function parsePluginSlug($slug) { if ( $slug === null ) { return ''; } if ( !is_string($slug) ) { throw new \InvalidArgumentException('Invalid plugin slug'); } $slug = sanitize_title($slug); //Slug should not be longer than, say, 64 characters. return substr($slug, 0, 64); } /** @noinspection PhpLanguageLevelInspection */ #[\ReturnTypeWillChange] public function jsonSerialize() { return [ 'pluginName' => $this->pluginName, 'pluginSlug' => $this->pluginSlug, 'pluginVersion' => $this->pluginVersion, 'pluginUrl' => $this->pluginUrl, 'authorName' => $this->authorName, 'authorUrl' => $this->authorUrl, 'requiredWpVersion' => $this->requiredWpVersion, 'testedWpVersion' => $this->testedWpVersion, 'shortDescription' => $this->shortDescription, 'identifierPrefix' => $this->identifierPrefix, 'wasEverConfirmed' => $this->wasEverConfirmed, ]; } } class AcAdminColorSchemeData { public $mainCss = ''; public $adminBarCss = ''; public $colorMeta = [ 'demo' => [], 'icons' => [], ]; public function __construct($mainCss, $adminBarCss, $colorMeta) { $this->mainCss = $mainCss; $this->adminBarCss = $adminBarCss; $this->colorMeta = $colorMeta; } } class AcAdminTheme { /** * @var AcAdminThemeMetadata */ public $meta; /** * @var array */ protected $files = []; /** * @var array */ public $settings = []; /** * @param \YahnisElsts\AdminMenuEditor\AdminCustomizer\AcAdminThemeMetadata $metadata * @param AbstractSetting[] $settings */ public function __construct(AcAdminThemeMetadata $metadata, $settings = []) { $this->meta = $metadata; foreach (AbstractSetting::recursivelyIterateSettings($settings, true) as $setting) { $this->settings[$setting->getId()] = $setting->getValue(); } } public function toZipString() { if ( !class_exists('ZipArchive') ) { throw new \RuntimeException('ZipArchive class is not available on this server.'); } $files = $this->files; $files['admin-theme.php'] = $this->populateTemplate('admin-theme.php'); $files['readme.txt'] = $this->populateTemplate('readme.txt'); $files['settings.json'] = wp_json_encode($this->settings, JSON_PRETTY_PRINT); $files['metadata.json'] = wp_json_encode($this->meta, JSON_PRETTY_PRINT); $tempFileName = get_temp_dir() . uniqid('ac-cat-') . '.zip'; $directoryName = $this->meta->getEffectivePluginSlug(); $zip = new \ZipArchive(); if ( $zip->open($tempFileName, \ZipArchive::CREATE) !== true ) { throw new \RuntimeException('Failed to create temporary ZIP file.'); } $zip->addEmptyDir($directoryName); foreach ($files as $fileName => $fileContents) { $zip->addFromString($directoryName . '/' . $fileName, $fileContents); } $zip->close(); //phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- Local temp file. $content = file_get_contents($tempFileName); //phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink -- This is a temporary file. unlink($tempFileName); return $content; } public function getZipFileName() { return $this->meta->getEffectivePluginSlug() . '.zip'; } public function setMainStylesheet($css) { $this->files['custom-admin-theme.css'] = $css; } public function setColorScheme(AcAdminColorSchemeData $colorScheme) { $this->files['color-scheme.css'] = $colorScheme->mainCss; if ( !empty($colorScheme->adminBarCss) ) { $this->files['admin-bar-colors.css'] = $colorScheme->adminBarCss; } $this->files['color-scheme.json'] = wp_json_encode($colorScheme->colorMeta, JSON_PRETTY_PRINT); } protected function populateTemplate($relativeFileName) { $template = file_get_contents(__DIR__ . '/admin-theme-template/' . $relativeFileName); return $this->replacePlaceholders($template); } protected function replacePlaceholders($content) { $placeholders = [ 'pluginName', 'pluginUrl', 'pluginSlug', 'pluginVersion', 'authorName', 'authorUrl', 'requiredWpVersion', 'testedWpVersion', 'shortDescription', 'identifierPrefix', 'optionalPluginHeaders', ]; $values = []; foreach ($placeholders as $placeholder) { if ( isset($this->meta->$placeholder) ) { $values[$placeholder] = $this->meta->$placeholder; } } $values['randomHash'] = substr(sha1(time() . '|' . wp_rand() . '|' . $this->meta->pluginVersion), 0, 8); if ( empty($values['optionalPluginHeaders']) ) { $optionalHeaderNames = [ 'Plugin URI' => 'pluginUrl', 'Author' => 'authorName', 'Author URI' => 'authorUrl', ]; $optionalHeaders = []; foreach ($optionalHeaderNames as $headerName => $metaKey) { if ( isset($this->meta->$metaKey) && !empty($this->meta->$metaKey) ) { $optionalHeaders[] = ' * ' . $headerName . ': ' . $this->meta->$metaKey; } } $values['optionalPluginHeaders'] = implode("\n", $optionalHeaders); } //The syntax is `{placeholder}`. foreach ($values as $placeholder => $value) { $content = str_replace('{' . $placeholder . '}', $value, $content); } //acIdentPrefix is a special case, it's not in curly braces. return str_replace('acIdentPrefix', $this->meta->identifierPrefix, $content); } }