PluginProbe ʕ •ᴥ•ʔ
WP 2FA – Two-factor authentication for WordPress / 4.0.0
WP 2FA – Two-factor authentication for WordPress v4.0.0
4.0.0 1.7.1 2.0.0 2.0.1 2.1.0 2.2.0 2.2.1 2.3.0 2.4.0 2.4.1 2.4.2 2.5.0 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.8.0 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0 3.0.1 3.1.0 3.1.1 3.1.1.2 trunk 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.6.0 1.6.1 1.6.2 1.7.0
wp-2fa / includes / classes / Admin / Fly-Out / class-flyout.php
wp-2fa / includes / classes / Admin / Fly-Out Last commit date
assets 1 day ago class-flyout.php 1 day ago index.php 1 day ago
class-flyout.php
414 lines
1 <?php
2 /**
3 * Responsible for fly-out menu shown on some of the plugin pages.
4 *
5 * @package wp2fa
6 * @subpackage flyout
7 *
8 * @since 2.8.0
9 *
10 * @copyright 2026 Melapress
11 * @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
12 *
13 * @see https://wordpress.org/plugins/wp-2fa/
14 */
15
16 declare(strict_types=1);
17
18 namespace WP2FA\Admin\FlyOut;
19
20 use WP2FA\WP2FA;
21 use WP2FA\Admin\Helpers\WP_Helper;
22
23 // Exit if accessed directly.
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 if ( ! class_exists( '\WP2FA\Admin\FlyOut\FlyOut' ) ) {
29 /**
30 * Generates fly-out menu on the plugin admin screen.
31 *
32 * @since 2.8.0
33 */
34 class FlyOut {
35
36 private const ENQUEUE_NAME = 'mlp_flyout';
37 private const CONFIG_TRANSIENT_NAME = \WP_2FA_PREFIX . 'flyout_config_string';
38
39 /**
40 * Get the remote config URL, filterable for custom deployments.
41 *
42 * @return string
43 */
44 private static function get_remote_config_url(): string {
45 return \apply_filters( \WP_2FA_PREFIX . 'flyout_remote_config_url', 'https://melapress.com/downloads/plugins-files/wp-2fa-flyout-config.php' );
46 }
47
48 /**
49 * Array with the configuration of the fly-out menu
50 *
51 * @var array
52 *
53 * @since 2.8.0
54 */
55 private static $config = array();
56
57 /**
58 * Class cache for the current screen (if admin is on it)
59 *
60 * @var bool
61 *
62 * @since 2.8.0
63 */
64 private static $screen = null;
65
66 /**
67 * Inits the class and its hooks
68 *
69 * @return void
70 *
71 * @since 2.8.0
72 */
73 public static function init() {
74 if ( ! \is_admin() ) {
75 return;
76 } else {
77 self::load_config();
78 if ( ! empty( self::$config ) ) {
79 \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'admin_enqueue_scripts' ) );
80 \add_action( 'admin_head', array( __CLASS__, 'admin_head' ) );
81 \add_action( 'admin_footer', array( __CLASS__, 'admin_footer' ) );
82 }
83 }
84 }
85
86 /**
87 * Loads the external config for processing
88 *
89 * @return void
90 *
91 * @since 2.8.0
92 */
93 public static function load_config() {
94 $config = array();
95
96 $config = self::read_remote_config();
97
98 if ( false === $config ) {
99 return;
100 }
101
102 $defaults = array(
103 'plugin_screen' => '',
104 'icon_border' => '#0000ff',
105 'icon_right' => '40px',
106 'icon_bottom' => '40px',
107 'icon_image' => '',
108 'icon_padding' => '2px',
109 'icon_size' => '55px',
110 'menu_accent_color' => '#ca4a1f',
111 'custom_css' => '',
112 'menu_items' => array(),
113 );
114
115 $config = array_merge( $defaults, (array) $config );
116 if ( ! is_array( $config['plugin_screen'] ) ) {
117 $config['plugin_screen'] = array( $config['plugin_screen'] );
118 }
119
120 if ( WP_Helper::is_multisite() ) {
121 $config['plugin_screen'] = array_map(
122 function ( $screen ) {
123 return in_array( $screen, WP_Helper::PLUGIN_PAGES, true ) ? $screen . '-network' : null;
124 },
125 $config['plugin_screen']
126 );
127 $config['plugin_screen'] = array_filter( $config['plugin_screen'] );
128 }
129
130 self::$config = $config;
131 }
132
133 /**
134 * Checks the current screen and returns true if it is the plugin one
135 *
136 * @return boolean
137 *
138 * @since 2.8.0
139 */
140 public static function is_plugin_screen(): bool {
141
142 if ( \is_null( self::$screen ) ) {
143
144 $screen = \get_current_screen();
145 self::$screen = false;
146
147 if ( null !== $screen && in_array( $screen->id, self::$config['plugin_screen'], true ) ) {
148 self::$screen = true;
149 }
150 }
151
152 return self::$screen;
153 }
154
155 /**
156 * Loads the fly-out css and JS files
157 *
158 * @return void
159 *
160 * @since 2.8.0
161 */
162 public static function admin_enqueue_scripts() {
163 if ( false === self::is_plugin_screen() ) {
164 return;
165 }
166
167 \wp_enqueue_style(
168 self::ENQUEUE_NAME,
169 WP_2FA_URL . '/includes/classes/Admin/Fly-Out/assets/css/flyout.css',
170 array(),
171 WP_2FA_VERSION
172 );
173 \wp_enqueue_script(
174 self::ENQUEUE_NAME,
175 WP_2FA_URL . '/includes/classes/Admin/Fly-Out/assets/js/flyout.js',
176 array(),
177 WP_2FA_VERSION,
178 true
179 );
180 }
181
182 /**
183 * Writes additional custom code in the header of the page
184 *
185 * @return void
186 *
187 * @since 2.8.0
188 */
189 public static function admin_head() {
190 if ( false === self::is_plugin_screen() ) {
191 return;
192 }
193
194 $out = '<style type="text/css">';
195 $out .= '#mlp-flyout {
196 right: ' . \sanitize_text_field( self::$config['icon_right'] ) . ';
197 bottom: ' . \sanitize_text_field( self::$config['icon_bottom'] ) . ';
198 }';
199 $out .= '#mlp-flyout #mlp-elements-image-wrapper {
200 border: ' . \sanitize_text_field( self::$config['icon_border'] ) . ';
201 }';
202 $out .= '#mlp-flyout #mlp-elements-button img {
203 padding: ' . \sanitize_text_field( self::$config['icon_padding'] ) . ';
204 width: ' . \sanitize_text_field( self::$config['icon_size'] ) . ';
205 height: ' . \sanitize_text_field( self::$config['icon_size'] ) . ';
206 }';
207 $out .= '#mlp-flyout .mlp-elements-menu-item.accent {
208 background: ' . \sanitize_text_field( self::$config['menu_accent_color'] ) . ';
209 }';
210 $out .= \wp_strip_all_tags( self::$config['custom_css'] );
211
212 // Generate dynamic CSS for menu items to support unlimited items.
213 $menu_items = array_reverse( self::$config['menu_items'] );
214 $i = 0;
215 foreach ( $menu_items as $item ) {
216 if ( ( isset( $item['type'] ) && 'all' === $item['type'] ) || ( isset( $item['type'] ) && WP2FA::get_plugin_version() === $item['type'] ) ) {
217 ++$i;
218 $bottom = 75 + ( ( $i - 1 ) * 55 );
219 $delay = 30 + ( ( $i - 1 ) * 40 );
220 $out .= ".mlp-elements-menu-item-{$i} { bottom: {$bottom}px; transition: transform 0.2s {$delay}ms, background-color 0.2s; }";
221 }
222 }
223 $out .= '</style>';
224
225 // The output below is built from sanitized and escaped values such
226 // as sanitize_text_field and wp_strip_all_tags. PHPCS cannot
227 // always infer that across a large concatenated string, so the
228 // OutputNotEscaped sniff is ignored here to avoid a false positive.
229 // If this is refactored to echo parts individually the ignore can
230 // be removed.
231 echo $out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
232 }
233
234 /**
235 * Writes additional custom code in the footer of the page
236 *
237 * @return void
238 *
239 * @since 2.8.0
240 */
241 public static function admin_footer() {
242 if ( false === self::is_plugin_screen() ) {
243 return;
244 }
245
246 $out = '';
247 $icons_url = WP_2FA_URL . 'dist/images/';
248 $default_link_item = array(
249 'class' => '',
250 'href' => '#',
251 'target' => '_blank',
252 'label' => '',
253 'icon' => '',
254 'data' => '',
255 'type' => 'all',
256 );
257
258 $out .= '<div id="mlp-overlay"></div>';
259
260 $out .= '<div id="mlp-flyout">';
261
262 $out .= '<a href="#" id="mlp-elements-button" aria-expanded="false" aria-label="' . esc_attr__( 'Toggle Quick Links Menu', 'wp-2fa' ) . '">';
263 $out .= '<span class="mlp-elements-label">Open Quick Links</span>';
264 $out .= '<span id="mlp-elements-image-wrapper">';
265 if ( ! empty( self::$config['icon_image'] ) ) {
266 $out .= '<img src="' . \esc_url( $icons_url . self::$config['icon_image'] ) . '" alt="Open Quick Links" title="Open Quick Links">';
267 }
268 $out .= '</span>';
269 $out .= '</a>';
270
271 $out .= '<div id="mlp-elements-menu">';
272 $i = 0;
273 foreach ( array_reverse( self::$config['menu_items'] ) as $item ) {
274 $item = array_merge( $default_link_item, $item );
275
276 if ( ( isset( $item['type'] ) && 'all' === $item['type'] ) || ( isset( $item['type'] ) && WP2FA::get_plugin_version() === $item['type'] ) ) {
277 ++$i;
278
279 if ( ! empty( $item['icon'] ) && \str_starts_with( $item['icon'], 'dashicons' ) ) {
280 $item['class'] .= ' mlp-elements-custom-icon';
281 $item['class'] = trim( $item['class'] );
282 }
283
284 $pattern = '/^data-[a-z0-9\-]+=(["\'])([A-Za-z0-9_\-]+)\1$/';
285 if ( ! preg_match( $pattern, $item['data'] ) ) {
286 $item['data'] = '';
287 }
288
289 $out .= '<a ' . $item['data'] . ' href="' . \esc_url( $item['href'] ) . '" class="mlp-elements-menu-item mlp-elements-menu-item-' . $i . ' ' . \esc_attr( $item['class'] ) . '" target="' . \esc_attr( $item['target'] ) . '">';
290 $out .= '<span class="mlp-elements-label visible">' . esc_html( $item['label'] ) . '</span>';
291 if ( \str_starts_with( $item['icon'], 'dashicons' ) ) {
292 $out .= '<span class="dashicons ' . \sanitize_text_field( $item['icon'] ) . '"></span>';
293 } elseif ( ! empty( $item['icon'] ) ) {
294 $out .= '<span class="mlp-elements-icon"><img src="' . \esc_url( $icons_url . $item['icon'] ) . '"></span>';
295 }
296 $out .= '</a>';
297 }
298 } // foreach
299 $out .= '</div>'; // #mlp-elements-menu
300
301 $out .= '</div>'; // #mlp-flyout
302
303 // The footer output is built from escaped and sanitized values such
304 // as esc_url, esc_attr and esc_html. PHPCS cannot reliably infer
305 // that across concatenation, so the OutputNotEscaped sniff is
306 // ignored here to avoid noise.
307 echo $out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
308 }
309
310 /**
311 * Reads the config file remotely and sets 2 days transient for caching. If for some reason cant read the remote - false is returned
312 *
313 * @return bool|array|false
314 *
315 * @since 2.8.0
316 */
317 public static function read_remote_config() {
318 $config = \get_transient( self::CONFIG_TRANSIENT_NAME );
319
320 // Transient used to throttle remote requests on repeated failures.
321 $timeout_transient_key = WP_2FA_PREFIX . 'flyout_config_timeout';
322 // Allow filter to adjust timeout TTL (default 1 hour).
323 $timeout_ttl = (int) apply_filters( WP_2FA_PREFIX . 'flyout_remote_timeout_ttl', HOUR_IN_SECONDS );
324
325 // If we recently had a failure, bail early to avoid hammering remote host.
326 if ( \get_transient( $timeout_transient_key ) ) {
327 // Intentionally not logging here to avoid flooding logs when the
328 // timeout transient is active.
329 return false;
330 }
331
332 if ( false === $config || empty( $config ) || ! is_array( $config ) ) {
333 $request_args = \apply_filters(
334 WP_2FA_PREFIX . 'flyout_remote_request_args',
335 array(
336 'timeout' => 30,
337 'redirection' => 5,
338 'user-agent' => 'WP-2FA-FlyOut/' . WP_2FA_VERSION,
339 )
340 );
341
342 $api_response = \wp_remote_get( self::get_remote_config_url(), $request_args );
343
344 if ( \is_wp_error( $api_response ) ) {
345 self::log_remote_error( 'Request error: ' . $api_response->get_error_message() );
346 // Set timeout transient so we don't retry immediately.
347 \set_transient( $timeout_transient_key, true, $timeout_ttl );
348 return false;
349 }
350
351 $response_code = \wp_remote_retrieve_response_code( $api_response );
352
353 if ( 200 !== (int) $response_code ) {
354 self::log_remote_error( 'Unexpected response code: ' . $response_code );
355 // Set timeout transient so we don't retry immediately.
356 \set_transient( $timeout_transient_key, true, $timeout_ttl );
357 return false;
358 }
359
360 $config_body = \wp_remote_retrieve_body( $api_response );
361 if ( empty( $config_body ) ) {
362 self::log_remote_error( 'Empty response body.' );
363 // Set timeout transient so we don't retry immediately.
364 \set_transient( $timeout_transient_key, true, $timeout_ttl );
365 return false;
366 }
367
368 $decoded_config = \json_decode( $config_body, true );
369 if ( null === $decoded_config ) {
370 self::log_remote_error( 'Invalid JSON payload.' );
371 // Set timeout transient so we don't retry immediately.
372 \set_transient( $timeout_transient_key, true, $timeout_ttl );
373 return false;
374 }
375
376 \set_transient( self::CONFIG_TRANSIENT_NAME, $decoded_config, \DAY_IN_SECONDS * 3 );
377
378 return $decoded_config;
379 }
380
381 // If cached config is a string (legacy), decode it; otherwise, return the array.
382 if ( is_string( $config ) ) {
383 $config = json_decode( $config, true );
384 if ( json_last_error() !== JSON_ERROR_NONE ) {
385 self::log_remote_error( 'Cached config decode failed.' );
386 return false;
387 }
388 }
389
390 return $config;
391 }
392
393 /**
394 * Logs remote fetch issues for debugging.
395 *
396 * @param string $message Error details.
397 *
398 * @return void
399 *
400 * @since 3.1.0
401 */
402 private static function log_remote_error( string $message ): void {
403 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
404 // Logging is conditional on WP_DEBUG and intended for
405 // developer troubleshooting. Allow error_log in this context;
406 // ignore the PHPCS development-function sniff for this helper
407 // call.
408 \error_log( '[WP 2FA FlyOut] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
409 }
410 \do_action( WP_2FA_PREFIX . 'flyout_remote_error', $message );
411 }
412 }
413 }
414