'Setting not found: ' . $settingId )); $response->addNote(sprintf('%s: %s', $settingId, 'Setting not found.')); continue; } //Check user permissions. if ( !$setting->isEditableByUser() ) { $validity->addError(new \WP_Error( 'ame_permission_denied', 'You do not have permission to change this setting.' )); $response->addNote(sprintf('%s: %s', $settingId, 'You do not have permission to change this setting.')); continue; } //Validate. $validationResult = $setting->validate(new \WP_Error(), $value); if ( is_wp_error($validationResult) ) { $validity->addError($validationResult); $response->addNote(sprintf('%s: Validation error: %s', $settingId, $validationResult->get_error_message())); continue; } $sanitizedValue = $validationResult; //Finally, update the value. $changeset->setSettingValue($settingId, $sanitizedValue); $successfulChanges++; $response->addNote(sprintf('%s: %s', $settingId, 'Value updated.')); $validity->setValid(true); } $response->setValidationResults($validationResults); $response->setChangeset($changeset); $hasValidationErrors = false; foreach ($validationResults as $validity) { if ( !$validity->isValid() ) { $hasValidationErrors = true; break; } } if ( $hasValidationErrors && !$partialUpdatesAllowed ) { wp_send_json_error( $response->error('There were one or more validation errors. Changes were not saved.'), 400 ); exit; } //Parse and validate adminThemeMetadata. $wasAdminThemeMetadataModified = false; if ( $hasThemeMetadata ) { $inputMetadata = json_decode((string)($post['adminThemeMetadata']), true); if ( !$inputMetadata && (json_last_error() !== JSON_ERROR_NONE) ) { $errorMsg = json_last_error_msg(); wp_send_json_error( $response->error('Invalid "adminThemeMetadata" parameter: ' . $errorMsg), 400 ); exit; } if ( !is_array($inputMetadata) && ($inputMetadata !== null) ) { wp_send_json_error( ['message' => 'Invalid "adminThemeMetadata" parameter. It must be a JSON object or NULL.'], 400 ); exit; } if ( is_array($inputMetadata) ) { try { $adminThemeMetadata = AcAdminThemeMetadata::parseArray($inputMetadata); } catch (\InvalidArgumentException $e) { wp_send_json_error( $response->error('Invalid "adminThemeMetadata" parameter: ' . $e->getMessage()), 400 ); exit; } } else { $adminThemeMetadata = null; } //Store the metadata. $changeset->setAdminThemeMetadata($adminThemeMetadata); $wasAdminThemeMetadataModified = true; $response->addNote('adminThemeMetadata updated.'); } //A published or trashed changeset cannot be modified. $currentStatus = $changeset->getStatus(); if ( in_array($currentStatus, ['publish', 'future', 'trash']) ) { wp_send_json_error( $response->error('This changeset is already published or trashed, so it cannot be modified.'), 400 ); exit; } //Will the status change? $newStatus = null; if ( !empty($post['status']) && AcChangeset::isSupportedStatus($post['status']) && ($post['status'] !== $currentStatus) ) { $newStatus = $post['status']; } //We don't support deleting changesets through this endpoint. if ( $newStatus === 'trash' ) { wp_send_json_error( $response->error('This endpoint does not support changeset deletion.'), 400 ); exit; } //Is the user allowed to apply the new status? if ( $newStatus !== null ) { $postId = $changeset->getPostId(); if ( ($newStatus === 'publish') || ($newStatus === 'future') ) { if ( $postId ) { $allowed = current_user_can('publish_post', $postId); } else { $allowed = current_user_can($postType->cap->publish_posts); } if ( !$allowed ) { wp_send_json_error( $response->error('You are not allowed to publish or schedule this changeset.'), 403 ); exit; } } } $response->mergeWith(['updatedValues' => $successfulChanges]); if ( ($successfulChanges > 0) || $createNewChangeset || ($newStatus !== null) || $wasAdminThemeMetadataModified ) { $changeset->setLastModifiedToNow(); $saved = $this->saveChangeset($changeset, $newStatus); if ( is_wp_error($saved) ) { wp_send_json_error( $response->error( 'Error saving changeset: ' . $saved->get_error_message(), $saved->get_error_code() ), 500 ); exit; } //If the changeset was published or trashed, the customizer will need //a new changeset. if ( in_array($changeset->getStatus(), ['publish', 'future', 'trash']) ) { $this->createNewChangeset()->each( function (AcChangeset $newChangeset) use ($response) { $response->mergeWith(['nextChangeset' => $newChangeset->getName()]); } ); } wp_send_json_success( $response->mergeWith([ 'message' => 'Changeset saved.', 'changesetWasPublished' => ($newStatus === 'publish'), ]), 200 //Requires WP 4.7+ ); } else { //We don't need to save the changeset if there are no actual changes, //but this is still technically a success. wp_send_json_success( $response->mergeWith(['message' => 'No changes were made.']), 200 //Requires WP 4.7+ ); } } public function ajaxTrashChangeset() { check_ajax_referer(self::TRASH_CHANGESET_ACTION); if ( !$this->userCanAccessModule() ) { wp_send_json_error( ['message' => 'You are not allowed to edit Admin Customizer changesets.'], 403 ); exit; } $postType = get_post_type_object(self::CHANGESET_POST_TYPE); if ( !$postType ) { wp_send_json_error(['message' => 'The changeset post type is missing.'], 500); exit; } $post = $this->menuEditor->get_post_params(); $csOption = $this->loadChangeset((string)($post['changeset'])); if ( $csOption->isEmpty() ) { wp_send_json_error(['message' => 'Changeset not found.'], 400); exit; } $changeset = $csOption->get(); $currentStatus = $changeset->getStatus(); if ( $currentStatus === 'trash' ) { wp_send_json_error(['message' => 'This changeset is already trashed.'], 400); exit; } $postId = $changeset->getPostId(); if ( isset($postType->cap->delete_post) ) { $allowed = current_user_can($postType->cap->delete_post, $postId); } else { $allowed = current_user_can('delete_post', $postId); } if ( !$allowed ) { wp_send_json_error(['message' => 'You are not allowed to trash this changeset.'], 403); exit; } if ( !wp_trash_post($postId) ) { wp_send_json_error(['message' => 'Unexpected error while moving the changeset to Trash.'], 500); } else { wp_send_json_success(['message' => 'Changeset trashed.'], 200); } exit; } /** * @param string $newStatus * @param string $oldStatus * @param \WP_Post $post * @return void */ public function applyChangesOnPublish($newStatus, $oldStatus, $post) { if ( $oldStatus === $newStatus ) { return; //No change. } $isChangesetBeingPublished = ($newStatus === 'publish') && ($post instanceof \WP_Post) && ($post->post_type === self::CHANGESET_POST_TYPE); if ( !$isChangesetBeingPublished ) { return; } //Instantiate a changeset from the post. $option = AcChangeset::fromPost($post); if ( !$option->isDefined() ) { return; //Not a valid changeset. } $changeset = $option->get(); /** @var AcChangeset $changeset */ //Ensure settings are registered. $this->registerCustomizableItems(); //Validate, update settings, then save all updated settings. $affectedSettings = []; foreach ($changeset as $settingId => $value) { $setting = $this->getSettingById($settingId); if ( $setting === null ) { continue; } $validationResult = $setting->validate(new \WP_Error(), $value, true); if ( is_wp_error($validationResult) ) { continue; } $sanitizedValue = $validationResult; $setting->update($sanitizedValue); $affectedSettings[] = $setting; } AbstractSetting::saveAll($affectedSettings); //Trash the changeset after it has been applied. We don't want to keep old //changesets in the database - they take up space and serve no useful purpose. wp_trash_post($post->ID); } public function userCanAccessModule() { $requiredCapability = $this->getBasicAccessCapability(); if ( !empty($requiredCapability) ) { return current_user_can($requiredCapability); } return $this->menuEditor->current_user_can_edit_menu(); } /** * @return string|null */ private function getBasicAccessCapability() { $result = apply_filters('admin_menu_editor-ac_access_capability', null); if ( !is_string($result) && !is_null($result) ) { throw new \RuntimeException('Invalid return value from the admin_menu_editor-ac_access_capability filter.'); } return $result; } /** * This method has side effects due to enabling preview for settings that * are part of the changeset. It is not safe to call it multiple times with * different changesets. * * @param \YahnisElsts\AdminMenuEditor\AdminCustomizer\AcChangeset $changeset * @param \YahnisElsts\AdminMenuEditor\AdminCustomizer\AcAdminThemeMetadata $metadata * @return \YahnisElsts\AdminMenuEditor\AdminCustomizer\AcAdminTheme */ private function createAdminTheme(AcChangeset $changeset, AcAdminThemeMetadata $metadata) { //We need the setting objects to find out which settings are tagged //as part of the admin theme, and also to enable preview so that we //can generate admin theme CSS using the current values. $this->registerCustomizableItems(); //Find admin theme settings. //Note: Even if some children of structs do not inherit the admin theme tag from //the parent, the children will still be included. $settings = array_filter( $this->registeredSettings, function (AbstractSetting $setting) { return $setting->hasTag(AbstractSetting::TAG_ADMIN_THEME); } ); //Enable preview for all admin theme settings that are in the changeset. //This way CSS generation should use the current values. foreach ($changeset as $settingId => $value) { if ( !isset($settings[$settingId]) ) { continue; } $settings[$settingId]->preview($value); } $adminTheme = new AcAdminTheme($metadata, $settings); //Generate admin theme CSS. $themeCssParts = []; $addThemeCss = function ($css) use (&$themeCssParts) { if ( !is_string($css) || empty($css) ) { return; } $themeCssParts[] = $css; }; //Modules can add CSS by using this action and calling $addThemeCss. do_action('admin_menu_editor-ac_admin_theme_css', $addThemeCss, $adminTheme); if ( empty($themeCssParts) ) { throw new \RuntimeException('The current settings did not generate any CSS for an admin theme.'); } $adminTheme->setMainStylesheet(implode("\n", $themeCssParts)); //Optionally, the admin theme can include an admin color scheme. $colorScheme = apply_filters('admin_menu_editor-ac_admin_theme_color_scheme', null, $adminTheme); if ( $colorScheme !== null ) { $adminTheme->setColorScheme($colorScheme); } return $adminTheme; } public function ajaxCreateAdminTheme() { check_ajax_referer(self::CREATE_THEME_ACTION); if ( !$this->userCanAccessModule() ) { wp_send_json_error( ['message' => 'You do not have permission to create an admin theme.'], 403 ); exit; } $post = $this->menuEditor->get_post_params(); //The changeset name must be specified explicitly. if ( !isset($post['changeset']) ) { wp_send_json_error( ['message' => 'The changeset name must be specified.'], 400 ); exit; } //The request must specify a cookie that will be used to detect that //the download has started. if ( !isset($post['downloadCookieName']) ) { wp_send_json_error( ['message' => 'The cookie name must be specified.'], 400 ); exit; } //For security, the cookie name must start with a known prefix and must //only contain alphanumeric characters and underscores. $cookieName = strval($post['downloadCookieName']); if ( substr($cookieName, 0, strlen(self::DOWNLOAD_COOKIE_PREFIX)) !== self::DOWNLOAD_COOKIE_PREFIX ) { wp_send_json_error(['message' => 'The cookie name is invalid.'], 400); exit; } if ( strlen($cookieName) > 200 ) { wp_send_json_error(['message' => 'The cookie name is too long.'], 400); exit; } if ( preg_match('/[^a-zA-Z0-9_]/', substr($cookieName, strlen(self::DOWNLOAD_COOKIE_PREFIX))) ) { wp_send_json_error(['message' => 'The cookie name contains invalid characters.'], 400); exit; } //Load the changeset and check permissions. $csOption = $this->loadChangeset((string)($post['changeset'])); if ( $csOption->isEmpty() ) { wp_send_json_error(['message' => 'Changeset not found.'], 400); exit; } $changeset = $csOption->get(); if ( !current_user_can('read_post', $changeset->getPostId()) ) { wp_send_json_error( ['message' => 'You do not have permission to read the specified changeset.'], 403 ); exit; } //Note: Any pending changes to the changeset should be saved before //calling this AJAX action. Otherwise, the admin theme will be generated //without those changes. //Get the admin theme metadata. if ( !isset($post['metadata']) ) { wp_send_json_error( ['message' => 'The admin theme metadata must be specified.'], 400 ); exit; } try { $metadata = AcAdminThemeMetadata::parseJson((string)$post['metadata']); } catch (\Exception $e) { wp_send_json_error( ['message' => $e->getMessage(), 'code' => 'metadata_parse_error'], 400 ); exit; } //Required WP version must be at least 4.7. if ( empty($metadata->requiredWpVersion) || version_compare($metadata->requiredWpVersion, '4.7', '<') ) { $metadata->requiredWpVersion = '4.7'; } //Tested WP version is at least the current WP version. try { $currentWpVersion = AcAdminThemeMetadata::parseVersionNumber(get_bloginfo('version')); } catch (\Exception $e) { $currentWpVersion = ''; } if ( !empty($currentWpVersion) && ( empty($metadata->testedWpVersion) || version_compare($metadata->testedWpVersion, $currentWpVersion, '<') ) ) { $metadata->testedWpVersion = $currentWpVersion; } //Create the admin theme. try { $adminTheme = $this->createAdminTheme($changeset, $metadata); $zipFileContent = $adminTheme->toZipString(); } catch (\Exception $e) { //There should be no exceptions here, but just in case. wp_send_json_error( ['message' => $e->getMessage(), 'code' => 'admin_theme_generation_error'], 500 ); exit; } //Set a cookie that will be used to detect that the download has started. $cookieValue = uniqid('', true); if ( version_compare(phpversion(), '7.3', '>=') ) { setcookie( $cookieName, $cookieValue, [ 'expires' => 0, //Session cookie. 'path' => '/', 'samesite' => 'Lax', 'secure' => is_ssl(), 'httponly' => false, //Our JS needs to read the cookie. ] ); } else { setcookie($cookieName, $cookieValue, 0, '/', '', is_ssl(), false); } //Output the file as a download. $filename = $adminTheme->getZipFileName(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($zipFileContent)); header('Content-Transfer-Encoding: binary'); header('Cache-Control: private, no-transform, no-store, must-revalidate'); header('Pragma: no-cache'); header('Expires: 0'); //phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $zipFileContent is binary data. echo $zipFileContent; //Remember when this changeset/admin theme was most recently downloaded. $postId = $changeset->getPostId(); if ( $postId ) { update_post_meta($postId, self::LAST_DOWNLOAD_META_KEY, time()); } exit; } } class AcChangeset implements \IteratorAggregate, \JsonSerializable, \Countable { /** * @var array */ private $settingValues = []; private $postId = null; private $name = null; private $lastModified; /** * @var null|AcAdminThemeMetadata */ private $adminThemeMetadata = null; private $status = null; public function __construct($name = null) { if ( $name !== null ) { $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); } }