Admin Menu Editor - Version 1.4.3

Version Description

  • Trying to delete a non-custom menu item will now trigger a warning dialog that offers to hide the item instead. In general, it's impossible to permanently delete menus created by WordPress itself or other plugins (without editing their source code, that is).
  • Added a workaround for a bug in W3 Total Cache 0.9.4.1 that could cause menu permissions to stop working properly when the CDN or New Relic modules were activated.
  • Fixed a plugin conflict where certain menu items didn't show up in the editor because the plugin that created them used a very low priority.
  • Signigicantly improved sanitization of menu properties.
  • Renamed the "Choose Icon" button to "Media Library".
  • Minor compatibility improvements.
Download this release

Release Info

Developer whiteshadow
Plugin Icon 128x128 Admin Menu Editor
Version 1.4.3
Comparing to
See all releases

Code changes from version 1.4.2 to 1.4.3

includes/editor-page.php CHANGED
@@ -256,7 +256,7 @@ if ( apply_filters('admin_menu_editor_is_pro', false) ) {
256
  <input type="button" class="button"
257
  id="ws_choose_icon_from_media"
258
  title="Upload an image or choose one from your media library"
259
- value="Choose Icon">
260
  <div class="clear"></div>
261
  <?php
262
  endif;
@@ -435,6 +435,12 @@ if ( apply_filters('admin_menu_editor_is_pro', false) ) {
435
  'ws_hide_menu_except_current_user',
436
  false
437
  );
 
 
 
 
 
 
438
  submit_button('Cancel', 'secondary', 'ws_cancel_menu_deletion', false);
439
  ?>
440
  </div>
256
  <input type="button" class="button"
257
  id="ws_choose_icon_from_media"
258
  title="Upload an image or choose one from your media library"
259
+ value="Media Library">
260
  <div class="clear"></div>
261
  <?php
262
  endif;
435
  'ws_hide_menu_except_current_user',
436
  false
437
  );
438
+ submit_button(
439
+ 'Hide it from everyone except Administrator',
440
+ 'secondary',
441
+ 'ws_hide_menu_except_administrator',
442
+ false
443
+ );
444
  submit_button('Cancel', 'secondary', 'ws_cancel_menu_deletion', false);
445
  ?>
446
  </div>
includes/menu-editor-core.php CHANGED
@@ -77,6 +77,11 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
77
  private $get = array();
78
  private $originalPost = array();
79
 
 
 
 
 
 
80
  function init(){
81
  $this->sitewide_options = true;
82
 
@@ -119,7 +124,9 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
119
  $this->settings_link = 'options-general.php?page=menu_editor';
120
 
121
  $this->magic_hooks = true;
122
- $this->magic_hook_priority = 99999;
 
 
123
 
124
  //AJAXify screen options
125
  add_action('wp_ajax_ws_ame_save_screen_options', array($this,'ajax_save_screen_options'));
@@ -147,6 +154,14 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
147
 
148
  //Tell first-time users where they can find the plugin settings page.
149
  add_action('all_admin_notices', array($this, 'display_plugin_menu_notice'));
 
 
 
 
 
 
 
 
150
  }
151
 
152
  function init_finish() {
@@ -301,6 +316,7 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
301
  $message .= '<p><strong>Admin Menu Editor security log</strong></p>';
302
  $message .= $this->get_formatted_security_log();
303
  }
 
304
  wp_die($message);
305
  } else {
306
  $this->log_security_note('ALLOW access.');
@@ -535,7 +551,7 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
535
  $users[$current_user->get('user_login')] = array(
536
  'user_login' => $current_user->get('user_login'),
537
  'id' => $current_user->ID,
538
- 'roles' => array_values($current_user->roles),
539
  'capabilities' => $this->castValuesToBool($current_user->caps),
540
  'is_super_admin' => is_multisite() && is_super_admin(),
541
  );
@@ -1483,6 +1499,9 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
1483
  return;
1484
  }
1485
 
 
 
 
1486
  //Save the custom menu
1487
  $this->set_custom_menu($menu);
1488
 
@@ -2480,6 +2499,73 @@ class WPMenuEditor extends MenuEd_ShadowPluginFramework {
2480
  }
2481
  }
2482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2483
  /**
2484
  * Tell new users how to access the plugin settings page.
2485
  */
77
  private $get = array();
78
  private $originalPost = array();
79
 
80
+ /**
81
+ * @var array A cache of user role names indexed by user ID. E.g. [123 => array("administrator", "foo")]
82
+ */
83
+ private $cached_user_roles = array();
84
+
85
  function init(){
86
  $this->sitewide_options = true;
87
 
124
  $this->settings_link = 'options-general.php?page=menu_editor';
125
 
126
  $this->magic_hooks = true;
127
+ //Run our hooks last (almost). Priority is less than PHP_INT_MAX mostly for defensive programming purposes.
128
+ //Old PHP versions have known bugs related to large array keys, and WP might have undiscovered edge cases.
129
+ $this->magic_hook_priority = PHP_INT_MAX - 10;
130
 
131
  //AJAXify screen options
132
  add_action('wp_ajax_ws_ame_save_screen_options', array($this,'ajax_save_screen_options'));
154
 
155
  //Tell first-time users where they can find the plugin settings page.
156
  add_action('all_admin_notices', array($this, 'display_plugin_menu_notice'));
157
+
158
+ //Workaround for buggy plugins that unintentionally remove user roles.
159
+ /** @see WPMenuEditor::get_user_roles */
160
+ add_action('set_current_user', array($this, 'update_current_user_cache'), 1, 0); //Run before most plugins.
161
+ add_action('updated_user_meta', array($this, 'clear_user_role_cache'), 10, 2);
162
+ add_action('deleted_user_meta', array($this, 'clear_user_role_cache'), 10, 2);
163
+ //There's also a "set_user_role" hook, but it's only called by WP_User::set_role and not WP_User::add_role.
164
+ //It's also redundant - WP_User::set_role updates user meta, so the above hooks already cover it.
165
  }
166
 
167
  function init_finish() {
316
  $message .= '<p><strong>Admin Menu Editor security log</strong></p>';
317
  $message .= $this->get_formatted_security_log();
318
  }
319
+ do_action('admin_page_access_denied');
320
  wp_die($message);
321
  } else {
322
  $this->log_security_note('ALLOW access.');
551
  $users[$current_user->get('user_login')] = array(
552
  'user_login' => $current_user->get('user_login'),
553
  'id' => $current_user->ID,
554
+ 'roles' => array_values($this->get_user_roles($current_user)),
555
  'capabilities' => $this->castValuesToBool($current_user->caps),
556
  'is_super_admin' => is_multisite() && is_super_admin(),
557
  );
1499
  return;
1500
  }
1501
 
1502
+ //Sanitize menu item properties.
1503
+ $menu['tree'] = ameMenu::sanitize($menu['tree']);
1504
+
1505
  //Save the custom menu
1506
  $this->set_custom_menu($menu);
1507
 
2499
  }
2500
  }
2501
 
2502
+ /**
2503
+ * Get the names of the roles that a user belongs to.
2504
+ *
2505
+ * "Why not just read the $user->roles array directly?", you may ask. Because some popular plugins have a really
2506
+ * nasty bug where they inadvertently remove entries from that array. Specifically, they retrieve the first user
2507
+ * role like this:
2508
+ *
2509
+ * $roleName = array_shift($currentUser->roles);
2510
+ *
2511
+ * What some plugin developers fail to realize is that, in addition to returning the first entry, array_shift()
2512
+ * also *removes* it from the array. As a result, $user->roles is now missing one of the user's roles. This bug
2513
+ * doesn't cause major problems only because most plugins check capabilities and don't care about roles as such.
2514
+ * AME needs to know to determine menu permissions for different roles.
2515
+ *
2516
+ * Known buggy plugins:
2517
+ * - W3 Total Cache 0.9.4.1
2518
+ *
2519
+ * The current workaround is to cache the role list before it can get corrupted by other plugins. This approach
2520
+ * has its own risks (cache invalidation is hard), but it should be reasonably safe assuming that everyone uses
2521
+ * only standard WP APIs to modify user roles (e.g. @see WP_User::add_role ).
2522
+ *
2523
+ * @param WP_User $user
2524
+ * @return array
2525
+ */
2526
+ public function get_user_roles($user) {
2527
+ if ( empty($user) ) {
2528
+ return array();
2529
+ }
2530
+ if ( !$user->exists() ) {
2531
+ return $user->roles;
2532
+ }
2533
+
2534
+ if ( !isset($this->cached_user_roles[$user->ID]) ) {
2535
+ //Note: In rare cases, WP_User::$roles can be false. For AME it's more convenient to have an empty list.
2536
+ $this->cached_user_roles[$user->ID] = !empty($user->roles) ? $user->roles : array();
2537
+ }
2538
+ return $this->cached_user_roles[$user->ID];
2539
+ }
2540
+
2541
+ /**
2542
+ * The current user has changed; cache their roles.
2543
+ */
2544
+ public function update_current_user_cache() {
2545
+ $user = wp_get_current_user();
2546
+ if ( empty($user) || !$user->exists() ) {
2547
+ return;
2548
+ }
2549
+
2550
+ $this->cached_user_roles[$user->ID] = $user->roles;
2551
+ }
2552
+
2553
+ /**
2554
+ * User metadata was updated or deleted; invalidate the role cache.
2555
+ *
2556
+ * Not all metadata updates are related to role changes, but filtering them is non-trivial (meta keys change)
2557
+ * and not really necessary for our purposes.
2558
+ *
2559
+ * @param int|array $unused_meta_id
2560
+ * @param int $user_id
2561
+ */
2562
+ public function clear_user_role_cache(/** @noinspection PhpUnusedParameterInspection */$unused_meta_id, $user_id) {
2563
+ if ( empty($user_id) || !is_numeric($user_id) ) {
2564
+ return;
2565
+ }
2566
+ unset($this->cached_user_roles[$user_id]);
2567
+ }
2568
+
2569
  /**
2570
  * Tell new users how to access the plugin settings page.
2571
  */
includes/menu-item.php CHANGED
@@ -124,13 +124,13 @@ abstract class ameMenuItem {
124
  $blank_menu = array_merge($blank_menu, array(
125
  'items' => array(), //List of sub-menu items.
126
  'grant_access' => array(), //Per-role and per-user access. Supersedes role_access.
127
- 'role_access' => array(), //Per-role access settings.
128
  'colors' => null,
129
 
130
  'custom' => false, //True if item is made-from-scratch and has no template.
131
  'missing' => false, //True if our template is no longer present in the default admin menu. Note: Stored values will be ignored. Set upon merging.
132
  'unused' => false, //True if this item was generated from an unused default menu. Note: Stored values will be ignored. Set upon merging.
133
  'hidden' => false, //Hide/show the item. Hiding is purely cosmetic, the item remains accessible.
 
134
 
135
  'defaults' => self::basic_defaults(),
136
  ));
@@ -319,7 +319,7 @@ abstract class ameMenuItem {
319
  foreach($item['role_access'] as $role_id => $has_access) {
320
  $item['grant_access']['role:' . $role_id] = $has_access;
321
  }
322
- $item['role_access'] = array();
323
  }
324
 
325
  if ( isset($item['items']) ) {
@@ -365,6 +365,66 @@ abstract class ameMenuItem {
365
  return $item;
366
  }
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  /**
369
  * Custom comparison function that compares menu items based on their position in the menu.
370
  *
124
  $blank_menu = array_merge($blank_menu, array(
125
  'items' => array(), //List of sub-menu items.
126
  'grant_access' => array(), //Per-role and per-user access. Supersedes role_access.
 
127
  'colors' => null,
128
 
129
  'custom' => false, //True if item is made-from-scratch and has no template.
130
  'missing' => false, //True if our template is no longer present in the default admin menu. Note: Stored values will be ignored. Set upon merging.
131
  'unused' => false, //True if this item was generated from an unused default menu. Note: Stored values will be ignored. Set upon merging.
132
  'hidden' => false, //Hide/show the item. Hiding is purely cosmetic, the item remains accessible.
133
+ 'separator' => false, //True if the item is a menu separator.
134
 
135
  'defaults' => self::basic_defaults(),
136
  ));
319
  foreach($item['role_access'] as $role_id => $has_access) {
320
  $item['grant_access']['role:' . $role_id] = $has_access;
321
  }
322
+ unset($item['role_access']);
323
  }
324
 
325
  if ( isset($item['items']) ) {
365
  return $item;
366
  }
367
 
368
+ /**
369
+ * Sanitize item properties.
370
+ *
371
+ * Strips disallowed HTML and invalid characters from many fields. For example, only users who
372
+ * have the "unfiltered_html" capability can use arbitrary HTML in menu titles.
373
+ *
374
+ * To avoid the performance hit of calling current_user_can('unfiltered_html') for every item,
375
+ * you can call it once and pass the result to this function.
376
+ *
377
+ * @param array $item Menu item in the internal format.
378
+ * @param bool|null $user_can_unfiltered_html
379
+ * @return array Sanitized menu item.
380
+ */
381
+ public static function sanitize($item, $user_can_unfiltered_html = null) {
382
+ if ( $user_can_unfiltered_html === null ) {
383
+ $user_can_unfiltered_html = current_user_can('unfiltered_html');
384
+ }
385
+
386
+ if ( !$user_can_unfiltered_html ) {
387
+ $kses_fields = array('menu_title', 'page_title', 'file', 'page_heading');
388
+ foreach($kses_fields as $field) {
389
+ $value = self::get($item, $field);
390
+ if ( is_string($value) && !empty($value) && !self::is_default($item, $field) ) {
391
+ $item[$field] = wp_kses_post($value);
392
+ }
393
+ }
394
+ }
395
+
396
+ //Sanitize CSS class names. Note that the WP implementation of sanitize_html_class() is very basic
397
+ //and doesn't comply with the CSS2 spec, but that's probably OK in this case.
398
+ $css_class = self::get($item, 'css_class');
399
+ if ( !self::is_default($item, 'css_class') && is_string($css_class) && function_exists('sanitize_html_class') ) {
400
+ $item['css_class'] = implode(' ', array_map('sanitize_html_class', explode(' ', $css_class)));
401
+ }
402
+
403
+ //While menu capabilities are generally not displayed anywhere except this plugin (which already
404
+ //escapes them properly), lets sanitize them anyway in case another plugin displays them as-is.
405
+ $capability_fields = array('access_level', 'extra_capability');
406
+ foreach($capability_fields as $field) {
407
+ $value = self::get($item, $field);
408
+ if ( !self::is_default($item, $field) && is_string($value) ) {
409
+ $item[$field] = strip_tags($value);
410
+ }
411
+ }
412
+
413
+ //Menu icons can be all kinds of stuff (dashicons, data URIs, etc), but they can't contain HTML.
414
+ //See /wp-admin/menu-header.php line #90 and onwards for how WordPress handles icons.
415
+ if ( !self::is_default($item, 'icon_url') ) {
416
+ $item['icon_url'] = strip_tags($item['icon_url']);
417
+ }
418
+
419
+ //WordPress already sanitizes the menu ID (hookname) on display, but, again, lets clean it just in case.
420
+ if ( !self::is_default($item, 'hookname') ) {
421
+ //Regex from menu-header.php, WP 4.1.
422
+ $item['hookname'] = preg_replace('@[^a-zA-Z0-9_:.]@', '-', self::get($item, 'hookname'));
423
+ }
424
+
425
+ return $item;
426
+ }
427
+
428
  /**
429
  * Custom comparison function that compares menu items based on their position in the menu.
430
  *
includes/menu.php CHANGED
@@ -1,7 +1,7 @@
1
  <?php
2
  abstract class ameMenu {
3
  const format_name = 'Admin Menu Editor menu';
4
- const format_version = '5.41';
5
 
6
  /**
7
  * Load an admin menu from a JSON string.
@@ -58,6 +58,10 @@ abstract class ameMenu {
58
  throw new InvalidMenuException("Failed to load a menu - the menu tree is missing.");
59
  }
60
 
 
 
 
 
61
  $menu = array('tree' => array());
62
  $menu = self::add_format_header($menu);
63
 
@@ -100,9 +104,15 @@ abstract class ameMenu {
100
  }
101
 
102
  private static function add_format_header($menu) {
103
- $menu['format'] = array(
104
- 'name' => self::format_name,
105
- 'version' => self::format_version,
 
 
 
 
 
 
106
  );
107
  return $menu;
108
  }
@@ -199,6 +209,31 @@ abstract class ameMenu {
199
  return false;
200
  }
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  /**
203
  * Recursively filter a list of menu items and remove items flagged as missing.
204
  *
@@ -220,6 +255,129 @@ abstract class ameMenu {
220
  protected static function is_not_missing($item) {
221
  return empty($item['missing']);
222
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
 
225
 
1
  <?php
2
  abstract class ameMenu {
3
  const format_name = 'Admin Menu Editor menu';
4
+ const format_version = '6.0';
5
 
6
  /**
7
  * Load an admin menu from a JSON string.
58
  throw new InvalidMenuException("Failed to load a menu - the menu tree is missing.");
59
  }
60
 
61
+ if ( isset($arr['format']) && !empty($arr['format']['compressed']) ) {
62
+ $arr = self::decompress($arr);
63
+ }
64
+
65
  $menu = array('tree' => array());
66
  $menu = self::add_format_header($menu);
67
 
104
  }
105
 
106
  private static function add_format_header($menu) {
107
+ if ( !isset($menu['format']) || !is_array($menu['format']) ) {
108
+ $menu['format'] = array();
109
+ }
110
+ $menu['format'] = array_merge(
111
+ $menu['format'],
112
+ array(
113
+ 'name' => self::format_name,
114
+ 'version' => self::format_version,
115
+ )
116
  );
117
  return $menu;
118
  }
209
  return false;
210
  }
211
 
212
+ /**
213
+ * Sanitize a list of menu items. Array indexes will be preserved.
214
+ *
215
+ * @param array $treeItems A list of menu items.
216
+ * @param bool $unfiltered_html Whether the current user has the unfiltered_html capability.
217
+ * @return array List of sanitized items.
218
+ */
219
+ public static function sanitize($treeItems, $unfiltered_html = null) {
220
+ if ( $unfiltered_html === null ) {
221
+ $unfiltered_html = current_user_can('unfiltered_html');
222
+ }
223
+
224
+ $result = array();
225
+ foreach($treeItems as $key => $item) {
226
+ $item = ameMenuItem::sanitize($item, $unfiltered_html);
227
+
228
+ if ( !empty($item['items']) ) {
229
+ $item['items'] = self::sanitize($item['items'], $unfiltered_html);
230
+ }
231
+ $result[$key] = $item;
232
+ }
233
+
234
+ return $result;
235
+ }
236
+
237
  /**
238
  * Recursively filter a list of menu items and remove items flagged as missing.
239
  *
255
  protected static function is_not_missing($item) {
256
  return empty($item['missing']);
257
  }
258
+
259
+ /**
260
+ * Compress menu configuration (lossless).
261
+ *
262
+ * Reduces data size by storing commonly used properties and defaults in one place
263
+ * instead of in every menu item.
264
+ *
265
+ * @param array $menu
266
+ * @return array
267
+ */
268
+ public static function compress($menu) {
269
+ $property_dict = ameMenuItem::blank_menu();
270
+ unset($property_dict['defaults']);
271
+
272
+ $common = array(
273
+ 'properties' => $property_dict,
274
+ 'basic_defaults' => ameMenuItem::basic_defaults(),
275
+ 'custom_item_defaults' => ameMenuItem::custom_item_defaults(),
276
+ );
277
+
278
+ $menu['tree'] = self::map_items(
279
+ $menu['tree'],
280
+ array(__CLASS__, 'compress_item'),
281
+ array($common)
282
+ );
283
+
284
+ $menu = self::add_format_header($menu);
285
+ $menu['format']['compressed'] = true;
286
+ $menu['format']['common'] = $common;
287
+
288
+ return $menu;
289
+ }
290
+
291
+ protected static function compress_item($item, $common) {
292
+ //These empty arrays can be dropped. They'll be restored either by merging common properties,
293
+ //or by ameMenuItem::normalize().
294
+ if ( empty($item['grant_access']) ) {
295
+ unset($item['grant_access']);
296
+ }
297
+ if ( empty($item['items']) ) {
298
+ unset($item['items']);
299
+ }
300
+
301
+ //Normal and custom menu items have different defaults.
302
+ //Remove defaults that are the same for all items of that type.
303
+ $defaults = !empty($item['custom']) ? $common['custom_item_defaults'] : $common['basic_defaults'];
304
+ if ( isset($item['defaults']) ) {
305
+ foreach($defaults as $key => $value) {
306
+ if ( array_key_exists($key, $item['defaults']) && $item['defaults'][$key] === $value ) {
307
+ unset($item['defaults'][$key]);
308
+ }
309
+ }
310
+ }
311
+
312
+ //Remove properties that match the common values.
313
+ foreach($common['properties'] as $key => $value) {
314
+ if ( array_key_exists($key, $item) && $item[$key] === $value ) {
315
+ unset($item[$key]);
316
+ }
317
+ }
318
+
319
+ return $item;
320
+ }
321
+
322
+ /**
323
+ * Decompress menu configuration that was previously compressed by ameMenu::compress().
324
+ *
325
+ * If the input $menu is not compressed, this method will return it unchanged.
326
+ *
327
+ * @param array $menu
328
+ * @return array
329
+ */
330
+ public static function decompress($menu) {
331
+ if ( !isset($menu['format']) || empty($menu['format']['compressed']) ) {
332
+ return $menu;
333
+ }
334
+
335
+ $common = $menu['format']['common'];
336
+ $menu['tree'] = self::map_items(
337
+ $menu['tree'],
338
+ array(__CLASS__, 'decompress_item'),
339
+ array($common)
340
+ );
341
+
342
+ unset($menu['format']['compressed'], $menu['format']['common']);
343
+ return $menu;
344
+ }
345
+
346
+ protected static function decompress_item($item, $common) {
347
+ $item = array_merge($common['properties'], $item);
348
+
349
+ $defaults = !empty($item['custom']) ? $common['custom_item_defaults'] : $common['basic_defaults'];
350
+ $item['defaults'] = array_merge($defaults, $item['defaults']);
351
+
352
+ return $item;
353
+ }
354
+
355
+ /**
356
+ * Recursively apply a callback to every menu item in an array and return the results.
357
+ * Array keys are preserved.
358
+ *
359
+ * @param array $items
360
+ * @param callable $callback
361
+ * @param array|null $extra_params Optional. An array of additional parameters to pass to the callback.
362
+ * @return array
363
+ */
364
+ protected static function map_items($items, $callback, $extra_params = null) {
365
+ if ( $extra_params === null ) {
366
+ $extra_params = array();
367
+ }
368
+
369
+ $result = array();
370
+ foreach($items as $key => $item) {
371
+ $args = array_merge(array($item), $extra_params);
372
+ $item = call_user_func_array($callback, $args);
373
+
374
+ if ( !empty($item['items']) ) {
375
+ $item['items'] = self::map_items($item['items'], $callback, $extra_params);
376
+ }
377
+ $result[$key] = $item;
378
+ }
379
+ return $result;
380
+ }
381
  }
382
 
383
 
js/menu-editor.js CHANGED
@@ -3,6 +3,7 @@
3
  /*global wsEditorData, defaultMenu, customMenu */
4
  /** @namespace wsEditorData */
5
 
 
6
  var wsIdCounter = 0;
7
 
8
  var AmeCapabilityManager = (function(roles, users) {
@@ -154,6 +155,9 @@ var AmeCapabilityManager = (function(roles, users) {
154
  return me;
155
  })(wsEditorData.roles, wsEditorData.users);
156
 
 
 
 
157
  (function ($){
158
 
159
  var selectedActor = null;
@@ -363,7 +367,7 @@ function buildMenuItem(itemData, isTopLevel) {
363
  itemData.separator ? '' : '<a class="ws_edit_link"> </a><div class="ws_flag_container"> </div>',
364
  '<input type="checkbox" class="ws_actor_access_checkbox">',
365
  '<span class="ws_item_title">',
366
- menuTitle,
367
  '&nbsp;</span>',
368
 
369
  '</div>',
@@ -414,6 +418,13 @@ function jsTrim(str){
414
  return str.replace(/^\s+|\s+$/g, "");
415
  }
416
 
 
 
 
 
 
 
 
417
  //Editor field spec template.
418
  var baseField = {
419
  caption : '[No caption]',
@@ -437,16 +448,12 @@ var knownMenuFields = {
437
  caption : 'Menu title',
438
  display: function(menuItem, displayValue, input, containerNode) {
439
  //Update the header as well.
440
- var itemTitle = displayValue;
441
- if (itemTitle === '') {
442
- itemTitle = '&nbsp;';
443
- }
444
- containerNode.find('.ws_item_title').html(itemTitle);
445
  return displayValue;
446
  },
447
  write: function(menuItem, value, input, containerNode) {
448
  menuItem.menu_title = value;
449
- containerNode.find('.ws_item_title').html(input.val() + '&nbsp;');
450
  }
451
  }),
452
 
@@ -798,11 +805,8 @@ function buildEditboxFields(fieldContainer, entry, isTopLevel){
798
  /*
799
  * Create an editor for a specified field.
800
  */
 
801
  function buildEditboxField(entry, field_name, field_settings){
802
- if (typeof entry[field_name] === 'undefined') {
803
- return null; //skip fields this entry doesn't have
804
- }
805
-
806
  //Build a form field of the appropriate type
807
  var inputBox = null;
808
  var basicTextField = '<input type="text" class="ws_field_value">';
@@ -1093,6 +1097,8 @@ function readMenuTreeState(){
1093
  };
1094
  }
1095
 
 
 
1096
  /**
1097
  * Extract the current menu item settings from its editor widget.
1098
  *
@@ -1233,6 +1239,8 @@ function actorCanAccessMenu(menuItem, actor) {
1233
  return actorHasAccess;
1234
  }
1235
 
 
 
1236
  function actorHasCustomPermissions(menuItem, actor) {
1237
  if (menuItem.grant_access && menuItem.grant_access.hasOwnProperty && menuItem.grant_access.hasOwnProperty(actor)) {
1238
  return (menuItem.grant_access[actor] !== null);
@@ -1338,6 +1346,9 @@ $(document).ready(function(){
1338
  $('.ws_hide_if_pro').hide();
1339
  }
1340
 
 
 
 
1341
  //Make the top menu box sortable (we only need to do this once)
1342
  var mainMenuBox = $('#ws_menu_box');
1343
  makeBoxSortable(mainMenuBox);
@@ -1949,7 +1960,7 @@ $(document).ready(function(){
1949
  //Create a custom media frame.
1950
  frame = wp.media.frames.customAdminMenuIcon = wp.media({
1951
  //Set the title of the modal.
1952
- title: 'Choose a Custom Icon (16x16)',
1953
 
1954
  //Tell it to show only images.
1955
  library: {
@@ -2185,26 +2196,46 @@ $(document).ready(function(){
2185
  menuDeletionDialog.dialog('close');
2186
  var selection = menuDeletionDialog.data('selected_menu');
2187
 
2188
- function hideRecursively(containerNode, exceptActor) {
2189
- denyAccessForAllExcept(containerNode.data('menu_item'), exceptActor);
2190
 
2191
  var subMenuId = containerNode.data('submenu_id');
2192
  if (subMenuId && containerNode.hasClass('ws_menu')) {
2193
  $('.ws_item', '#' + subMenuId).each(function() {
2194
  var node = $(this);
2195
- denyAccessForAllExcept(node.data('menu_item'), exceptActor);
2196
  updateItemEditor(node);
2197
  });
2198
  }
2199
 
2200
  updateItemEditor(containerNode);
 
 
 
 
 
 
2201
  updateParentAccessUi(containerNode);
2202
  }
2203
 
2204
  if (hide === 'all') {
2205
- hideRecursively(selection, null);
 
 
 
 
 
 
 
2206
  } else if (hide === 'except_current_user') {
2207
  hideRecursively(selection, 'user:' + wsEditorData.currentUserLogin);
 
 
 
 
 
 
 
2208
  }
2209
  };
2210
 
@@ -2218,6 +2249,9 @@ $(document).ready(function(){
2218
  $('#ws_hide_menu_except_current_user').click(function() {
2219
  menuDeletionCallback('except_current_user');
2220
  });
 
 
 
2221
 
2222
  /**
2223
  * Attempt to delete a menu item. Will check if the item can actually be deleted and ask the user for confirmation.
@@ -2247,9 +2281,8 @@ $(document).ready(function(){
2247
  });
2248
  }
2249
 
2250
- if (!isDefaultItem || otherCopiesExist || !wsEditorData.wsMenuEditorPro) {
2251
- //Custom and duplicate items can be deleted normally. The free version doesn't get the dialog
2252
- //because it doesn't have role-specific permissions.
2253
  shouldDelete = confirm('Delete this menu?');
2254
  } else {
2255
  //Non-custom items can not be deleted, but they can be hidden. Ask the user if they want to do that.
@@ -2257,6 +2290,12 @@ $(document).ready(function(){
2257
  menuItem.defaults.is_plugin_page ? 'an item added by another plugin' : 'a built-in menu item'
2258
  );
2259
  menuDeletionDialog.data('selected_menu', selection);
 
 
 
 
 
 
2260
  menuDeletionDialog.dialog('open');
2261
 
2262
  //Select "Cancel" as the default button.
@@ -2365,7 +2404,7 @@ $(document).ready(function(){
2365
  menu_title : 'Custom Menu ' + ws_paste_count,
2366
  file : randomId,
2367
  items: [],
2368
- defaults: itemTemplates.getDefaults('')
2369
  });
2370
 
2371
  //Make it accessible only to the current actor if one is selected.
@@ -2616,7 +2655,7 @@ $(document).ready(function(){
2616
  menu_title : 'Custom Item ' + ws_paste_count,
2617
  file : randomMenuId(),
2618
  items: [],
2619
- defaults: itemTemplates.getDefaults('')
2620
  });
2621
 
2622
  //Make it accessible to only the currently selected actor.
@@ -2803,7 +2842,7 @@ $(document).ready(function(){
2803
 
2804
  if ( (typeof data['download_url'] != 'undefined') && data.download_url ){
2805
  //window.location = data.download_url;
2806
- $('#download_menu_button').attr('href', data.download_url);
2807
  $('#export_progress_notice').hide();
2808
  $('#export_complete_notice, #download_menu_button').show();
2809
  }
3
  /*global wsEditorData, defaultMenu, customMenu */
4
  /** @namespace wsEditorData */
5
 
6
+ wsEditorData.wsMenuEditorPro = !!wsEditorData.wsMenuEditorPro; //Cast to boolean.
7
  var wsIdCounter = 0;
8
 
9
  var AmeCapabilityManager = (function(roles, users) {
155
  return me;
156
  })(wsEditorData.roles, wsEditorData.users);
157
 
158
+
159
+ var AmeEditorApi = {};
160
+
161
  (function ($){
162
 
163
  var selectedActor = null;
367
  itemData.separator ? '' : '<a class="ws_edit_link"> </a><div class="ws_flag_container"> </div>',
368
  '<input type="checkbox" class="ws_actor_access_checkbox">',
369
  '<span class="ws_item_title">',
370
+ stripAllTags(menuTitle),
371
  '&nbsp;</span>',
372
 
373
  '</div>',
418
  return str.replace(/^\s+|\s+$/g, "");
419
  }
420
 
421
+ function stripAllTags(input) {
422
+ //Based on: http://phpjs.org/functions/strip_tags/
423
+ var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi,
424
+ commentsAndPhpTags = /<!--[\s\S]*?-->|<\?(?:php)?[\s\S]*?\?>/gi;
425
+ return input.replace(commentsAndPhpTags, '').replace(tags, '');
426
+ }
427
+
428
  //Editor field spec template.
429
  var baseField = {
430
  caption : '[No caption]',
448
  caption : 'Menu title',
449
  display: function(menuItem, displayValue, input, containerNode) {
450
  //Update the header as well.
451
+ containerNode.find('.ws_item_title').html(stripAllTags(displayValue) + '&nbsp;');
 
 
 
 
452
  return displayValue;
453
  },
454
  write: function(menuItem, value, input, containerNode) {
455
  menuItem.menu_title = value;
456
+ containerNode.find('.ws_item_title').html(stripAllTags(input.val()) + '&nbsp;');
457
  }
458
  }),
459
 
805
  /*
806
  * Create an editor for a specified field.
807
  */
808
+ //noinspection JSUnusedLocalSymbols
809
  function buildEditboxField(entry, field_name, field_settings){
 
 
 
 
810
  //Build a form field of the appropriate type
811
  var inputBox = null;
812
  var basicTextField = '<input type="text" class="ws_field_value">';
1097
  };
1098
  }
1099
 
1100
+ AmeEditorApi.readMenuTreeState = readMenuTreeState;
1101
+
1102
  /**
1103
  * Extract the current menu item settings from its editor widget.
1104
  *
1239
  return actorHasAccess;
1240
  }
1241
 
1242
+ AmeEditorApi.actorCanAccessMenu = actorCanAccessMenu;
1243
+
1244
  function actorHasCustomPermissions(menuItem, actor) {
1245
  if (menuItem.grant_access && menuItem.grant_access.hasOwnProperty && menuItem.grant_access.hasOwnProperty(actor)) {
1246
  return (menuItem.grant_access[actor] !== null);
1346
  $('.ws_hide_if_pro').hide();
1347
  }
1348
 
1349
+ //Let other plugins filter knownMenuFields.
1350
+ $(document).trigger('filterMenuFields.adminMenuEditor', [knownMenuFields, baseField]);
1351
+
1352
  //Make the top menu box sortable (we only need to do this once)
1353
  var mainMenuBox = $('#ws_menu_box');
1354
  makeBoxSortable(mainMenuBox);
1960
  //Create a custom media frame.
1961
  frame = wp.media.frames.customAdminMenuIcon = wp.media({
1962
  //Set the title of the modal.
1963
+ title: 'Choose a Custom Icon (20x20)',
1964
 
1965
  //Tell it to show only images.
1966
  library: {
2196
  menuDeletionDialog.dialog('close');
2197
  var selection = menuDeletionDialog.data('selected_menu');
2198
 
2199
+ function applyCallbackRecursively(containerNode, callback) {
2200
+ callback(containerNode.data('menu_item'));
2201
 
2202
  var subMenuId = containerNode.data('submenu_id');
2203
  if (subMenuId && containerNode.hasClass('ws_menu')) {
2204
  $('.ws_item', '#' + subMenuId).each(function() {
2205
  var node = $(this);
2206
+ callback(node.data('menu_item'));
2207
  updateItemEditor(node);
2208
  });
2209
  }
2210
 
2211
  updateItemEditor(containerNode);
2212
+ }
2213
+
2214
+ function hideRecursively(containerNode, exceptActor) {
2215
+ applyCallbackRecursively(containerNode, function(menuItem) {
2216
+ denyAccessForAllExcept(menuItem, exceptActor);
2217
+ });
2218
  updateParentAccessUi(containerNode);
2219
  }
2220
 
2221
  if (hide === 'all') {
2222
+ if (wsEditorData.wsMenuEditorPro) {
2223
+ hideRecursively(selection, null);
2224
+ } else {
2225
+ //The free version doesn't have role permissions, so use the global "hidden" flag.
2226
+ applyCallbackRecursively(selection, function(menuItem) {
2227
+ menuItem.hidden = true;
2228
+ });
2229
+ }
2230
  } else if (hide === 'except_current_user') {
2231
  hideRecursively(selection, 'user:' + wsEditorData.currentUserLogin);
2232
+ } else if (hide === 'except_administrator' && !wsEditorData.wsMenuEditorPro) {
2233
+ //Set "required capability" to something only the Administrator role would have.
2234
+ var adminOnlyCap = 'manage_options';
2235
+ applyCallbackRecursively(selection, function(menuItem) {
2236
+ menuItem.extra_capability = adminOnlyCap;
2237
+ });
2238
+ alert('The "required capability" field was set to "' + adminOnlyCap + '".')
2239
  }
2240
  };
2241
 
2249
  $('#ws_hide_menu_except_current_user').click(function() {
2250
  menuDeletionCallback('except_current_user');
2251
  });
2252
+ $('#ws_hide_menu_except_administrator').click(function() {
2253
+ menuDeletionCallback('except_administrator');
2254
+ });
2255
 
2256
  /**
2257
  * Attempt to delete a menu item. Will check if the item can actually be deleted and ask the user for confirmation.
2281
  });
2282
  }
2283
 
2284
+ if (!isDefaultItem || otherCopiesExist) {
2285
+ //Custom and duplicate items can be deleted normally.
 
2286
  shouldDelete = confirm('Delete this menu?');
2287
  } else {
2288
  //Non-custom items can not be deleted, but they can be hidden. Ask the user if they want to do that.
2290
  menuItem.defaults.is_plugin_page ? 'an item added by another plugin' : 'a built-in menu item'
2291
  );
2292
  menuDeletionDialog.data('selected_menu', selection);
2293
+
2294
+ //Different versions get slightly different options because only the Pro version has
2295
+ //role-specific permissions.
2296
+ $('#ws_hide_menu_except_current_user').toggleClass('hidden', !wsEditorData.wsMenuEditorPro);
2297
+ $('#ws_hide_menu_except_administrator').toggleClass('hidden', wsEditorData.wsMenuEditorPro);
2298
+
2299
  menuDeletionDialog.dialog('open');
2300
 
2301
  //Select "Cancel" as the default button.
2404
  menu_title : 'Custom Menu ' + ws_paste_count,
2405
  file : randomId,
2406
  items: [],
2407
+ defaults: $.extend({}, itemTemplates.getDefaults(''))
2408
  });
2409
 
2410
  //Make it accessible only to the current actor if one is selected.
2655
  menu_title : 'Custom Item ' + ws_paste_count,
2656
  file : randomMenuId(),
2657
  items: [],
2658
+ defaults: $.extend({}, itemTemplates.getDefaults(''))
2659
  });
2660
 
2661
  //Make it accessible to only the currently selected actor.
2842
 
2843
  if ( (typeof data['download_url'] != 'undefined') && data.download_url ){
2844
  //window.location = data.download_url;
2845
+ $('#download_menu_button').attr('href', data.download_url).data('filesize', data.filesize);
2846
  $('#export_progress_notice').hide();
2847
  $('#export_complete_notice, #download_menu_button').show();
2848
  }
menu-editor.php CHANGED
@@ -3,7 +3,7 @@
3
  Plugin Name: Admin Menu Editor
4
  Plugin URI: http://w-shadow.com/blog/2008/12/20/admin-menu-editor-for-wordpress/
5
  Description: Lets you directly edit the WordPress admin menu. You can re-order, hide or rename existing menus, add custom menus and more.
6
- Version: 1.4.2
7
  Author: Janis Elsts
8
  Author URI: http://w-shadow.com/blog/
9
  */
3
  Plugin Name: Admin Menu Editor
4
  Plugin URI: http://w-shadow.com/blog/2008/12/20/admin-menu-editor-for-wordpress/
5
  Description: Lets you directly edit the WordPress admin menu. You can re-order, hide or rename existing menus, add custom menus and more.
6
+ Version: 1.4.3
7
  Author: Janis Elsts
8
  Author URI: http://w-shadow.com/blog/
9
  */
readme.txt CHANGED
@@ -3,8 +3,8 @@ Contributors: whiteshadow
3
  Donate link: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A6P9S6CE3SRSW
4
  Tags: admin, dashboard, menu, security, wpmu
5
  Requires at least: 3.8
6
- Tested up to: 4.1
7
- Stable tag: 1.4.2
8
 
9
  Lets you edit the WordPress admin menu. You can re-order, hide or rename menus, add custom menus and more.
10
 
@@ -63,6 +63,14 @@ Plugins installed in the `mu-plugins` directory are treated as "always on", so y
63
 
64
  == Changelog ==
65
 
 
 
 
 
 
 
 
 
66
  = 1.4.2 =
67
  * Tested on WP 4.1 and 4.2-alpha.
68
  * Fixed a bug that allowed Administrators to bypass custom permissions for the "Appearance -> Customize" menu item.
3
  Donate link: https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=A6P9S6CE3SRSW
4
  Tags: admin, dashboard, menu, security, wpmu
5
  Requires at least: 3.8
6
+ Tested up to: 4.1.1
7
+ Stable tag: 1.4.3
8
 
9
  Lets you edit the WordPress admin menu. You can re-order, hide or rename menus, add custom menus and more.
10
 
63
 
64
  == Changelog ==
65
 
66
+ = 1.4.3 =
67
+ * Trying to delete a non-custom menu item will now trigger a warning dialog that offers to hide the item instead. In general, it's impossible to permanently delete menus created by WordPress itself or other plugins (without editing their source code, that is).
68
+ * Added a workaround for a bug in W3 Total Cache 0.9.4.1 that could cause menu permissions to stop working properly when the CDN or New Relic modules were activated.
69
+ * Fixed a plugin conflict where certain menu items didn't show up in the editor because the plugin that created them used a very low priority.
70
+ * Signigicantly improved sanitization of menu properties.
71
+ * Renamed the "Choose Icon" button to "Media Library".
72
+ * Minor compatibility improvements.
73
+
74
  = 1.4.2 =
75
  * Tested on WP 4.1 and 4.2-alpha.
76
  * Fixed a bug that allowed Administrators to bypass custom permissions for the "Appearance -> Customize" menu item.