Plugin Directory

Changeset 3395335


Ignore:
Timestamp:
11/13/2025 09:17:41 PM (3 months ago)
Author:
wpfixit
Message:
  • Can now exclude single plugins from Site Lock
Location:
folder-auditor
Files:
73 added
6 edited

Legend:

Unmodified
Added
Removed
  • folder-auditor/trunk/folder-auditor.php

    r3395071 r3395335  
    33 * Plugin Name: Guard Dog Security & Site Lock
    44 * Description: Helps WordPress administrators take full control of their site. It scans critical areas including the root directory, wp-content, plugins, themes, uploads, and .htaccess files to detect anything suspicious such as orphaned folders, leftover files, or hidden PHP in uploads. From the WordPress dashboard, you can safely review, download, or remove items that don’t belong, with built-in protection to ensure required resources remain untouched. In addition, Guard Dog Security lets you lock all files and folders as read-only, preventing unauthorized changes, additions, or deletions to your WordPress installation.
    5  * Version: 4.9.1
     5 * Version: 4.9.2
    66 * Author: WP Fix It
    77 * Author URI: https://www.wpfixit.com
  • folder-auditor/trunk/includes/handlers/handler-actions.php

    r3374720 r3395335  
    3030        add_action( 'wp_ajax_folder_auditor_plugin_file_view',  [ $this, 'handle_plugin_file_view_ajax' ] );
    3131        add_action( 'admin_post_folder_auditor_plugins_root_bulk', [ $this, 'handle_plugins_root_bulk' ] );
     32    add_action( 'admin_post_folder_auditor_plugin_never_lock', array( $this, 'handle_plugin_never_lock' ) );
     33    add_action( 'admin_post_folder_auditor_plugin_allow_lock', array( $this, 'handle_plugin_allow_lock' ) );
     34
    3235
    3336        // === Theme Folder Actions ===
  • folder-auditor/trunk/includes/handlers/handler-plugins.php

    r3374418 r3395335  
    11<?php
    22
    3 
    43/**
    54
    6 
    75 * Plugin Handlers for Folder Auditor
    86
    9 
    107 *
    118
    12 
    139 * - Secure download/delete of arbitrary root files (as resolved by get_safe_root_file()).
    1410
    15 
    1611 * - Plugin folder ZIP downloads and recursive deletes.
    1712
    18 
    1913 * - Helpers to mirror the Plugins screen and list top-level plugin folders.
    2014
    21 
    2215 */
    2316
    24 
    2517if ( ! defined( 'ABSPATH' ) ) { exit; } // No direct access 👮
    2618
    27 
    2819trait WPFA_plugins_handler_functions {
    2920
     21/**
     22 * Toggle: mark a plugin folder as "Never Lock" (and unlock it right now).
     23 * Nonce action: 'fa_plugin_toggle_' . $slug
     24 * POST: slug
     25 */
     26public function handle_plugin_never_lock() {
     27    if ( ! $this->can_manage() ) {
     28        wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     29    }
     30
     31    $slug = (string) ( filter_input( INPUT_POST, 'slug', FILTER_UNSAFE_RAW ) ?? '' );
     32    $slug = sanitize_text_field( $slug );
     33    check_admin_referer( 'fa_plugin_toggle_' . $slug );
     34
     35    // Reuse the same safe folder resolver you already use for plugin actions
     36    $folder = $this->get_safe_folder_path( $slug );
     37    if ( ! $folder ) {
     38        wp_die( esc_html__( 'Invalid folder.', 'folder-auditor' ) );
     39    }
     40
     41    $list = (array) get_option( 'wpfa_never_lock_plugins', array() );
     42    if ( ! in_array( $slug, $list, true ) ) {
     43        $list[] = $slug;
     44        update_option( 'wpfa_never_lock_plugins', array_values( array_unique( $list ) ), true );
     45    }
     46
     47    // Immediately unlock this plugin folder and contents (same helper used for uploads)
     48    if ( method_exists( $this, 'fa_unlock_path_now' ) ) {
     49        $this->fa_unlock_path_now( $folder );
     50    }
     51
     52    $redirect = wp_get_referer();
     53    if ( ! $redirect ) {
     54        $redirect = admin_url( 'admin.php?page=guard-dog-security&tab=plugins' );
     55    }
     56    wp_safe_redirect( $redirect );
     57    exit;
     58}
     59
     60/**
     61 * Toggle: remove "Never Lock" so global locks can include it again.
     62 * Nonce action: 'fa_plugin_toggle_' . $slug
     63 * POST: slug
     64 */
     65public function handle_plugin_allow_lock() {
     66    if ( ! $this->can_manage() ) {
     67        wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     68    }
     69
     70    $slug = (string) ( filter_input( INPUT_POST, 'slug', FILTER_UNSAFE_RAW ) ?? '' );
     71    $slug = sanitize_text_field( $slug );
     72    check_admin_referer( 'fa_plugin_toggle_' . $slug );
     73
     74    $list = (array) get_option( 'wpfa_never_lock_plugins', array() );
     75    $new  = array();
     76
     77    foreach ( $list as $s ) {
     78        if ( (string) $s !== $slug ) {
     79            $new[] = (string) $s;
     80        }
     81    }
     82
     83    update_option( 'wpfa_never_lock_plugins', $new, true );
     84
     85    $redirect = wp_get_referer();
     86    if ( ! $redirect ) {
     87        $redirect = admin_url( 'admin.php?page=guard-dog-security&tab=plugins' );
     88    }
     89    wp_safe_redirect( $redirect );
     90    exit;
     91}
     92
     93/**
     94
     95 * Bulk actions for files directly under wp-content/plugins.
     96
     97 * POST fields mirror the .htaccess bulk UI:
     98
     99 *   bulk[row_key] = { delete | ignore | include }
     100
     101 *   file[row_key] = { file basename in wp-content/plugins }
     102
     103 *
     104
     105 * Nonce: 'fa_plugins_root_bulk'
     106
     107 * Action: admin_post_folder_auditor_plugins_root_bulk
     108
     109 */
     110
     111public function handle_plugins_root_bulk() {
     112
     113    if ( ! $this->can_manage() ) {
     114
     115        wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     116
     117    }
     118
     119    // CSRF
     120
     121    check_admin_referer( 'fa_plugins_root_bulk' );
     122
     123    // Fetch arrays safely
     124
     125    $bulk_raw = (array) ( filter_input( INPUT_POST, 'bulk', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
     126
     127    $file_raw = (array) ( filter_input( INPUT_POST, 'file', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
     128
     129    // Sanitize into new arrays
     130
     131    $bulk = array();
     132
     133    foreach ( $bulk_raw as $k => $v ) {
     134
     135        $bulk[ sanitize_text_field( (string) $k ) ] = sanitize_text_field( (string) $v );
     136
     137    }
     138
     139    $files = array();
     140
     141    foreach ( $file_raw as $k => $v ) {
     142
     143        $files[ sanitize_text_field( (string) $k ) ] = sanitize_file_name( (string) $v );
     144
     145    }
     146
     147    // Tally
     148
     149    $deleted = 0;
     150
     151    $ignored_added = 0;
     152
     153    $ignored_removed = 0;
     154
     155    // FS helper (if available)
     156
     157    $wp_filesystem = $this->fa_require_filesystem();
     158
     159    foreach ( $bulk as $row_key => $choice ) {
     160
     161        $file = isset( $files[ $row_key ] ) ? (string) $files[ $row_key ] : '';
     162
     163        if ( '' === $file ) { continue; }
     164
     165        if ( ! in_array( $choice, array( 'delete', 'ignore', 'include' ), true ) ) {
     166
     167            continue;
     168
     169        }
     170
     171        if ( 'delete' === $choice ) {
     172
     173            $abs = $this->get_safe_plugin_file( $file ); // only returns files in wp-content/plugins
     174
     175            if ( $abs && is_file( $abs ) ) {
     176
     177                // Prefer WP_Filesystem; fallback to wp_delete_file
     178
     179                $ok = true;
     180
     181                if ( $wp_filesystem && method_exists( $wp_filesystem, 'delete' ) ) {
     182
     183                    if ( method_exists( $wp_filesystem, 'chmod' ) ) {
     184
     185                        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
     186
     187                        @$wp_filesystem->chmod( $abs, FS_CHMOD_FILE );
     188
     189                    }
     190
     191                    $ok = (bool) $wp_filesystem->delete( $abs, false, 'f' );
     192
     193                } else {
     194
     195                    $ok = (bool) wp_delete_file( $abs );
     196
     197                }
     198
     199                if ( $ok ) {
     200
     201                    $deleted++;
     202
     203                    // If it was ignored, untrack it now
     204
     205                    $this->ignore_remove( 'plugins_root', $file );
     206
     207                }
     208
     209            }
     210
     211        } elseif ( 'ignore' === $choice ) {
     212
     213            $this->ignore_add( 'plugins_root', $file );
     214
     215            $ignored_added++;
     216
     217        } elseif ( 'include' === $choice ) {
     218
     219            $this->ignore_remove( 'plugins_root', $file );
     220
     221            $ignored_removed++;
     222
     223        }
     224
     225    }
     226
     227    // Redirect back to Plugins tab with a compact summary
     228
     229    $args = array(
     230
     231        'page'                          => self::MENU_SLUG,
     232
     233        'tab'                           => 'plugins',
     234
     235        'fa_plugins_root_deleted'       => (int) $deleted,
     236
     237        'fa_plugins_root_ignored'       => (int) $ignored_added,
     238
     239        'fa_plugins_root_included'      => (int) $ignored_removed,
     240
     241        // jump straight to the section
     242
     243        );
     244
     245    $args['#'] = 'plugins-root-files'; // anchor
     246
     247    wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
     248
     249    exit;
     250
     251}
    30252
    31253   
    32254
    33 
    34 /**
    35 
    36 
    37  * Bulk actions for files directly under wp-content/plugins.
    38 
    39 
    40  * POST fields mirror the .htaccess bulk UI:
    41 
    42 
    43  *   bulk[row_key] = { delete | ignore | include }
    44 
    45 
    46  *   file[row_key] = { file basename in wp-content/plugins }
    47 
    48 
    49  *
    50 
    51 
    52  * Nonce: 'fa_plugins_root_bulk'
    53 
    54 
    55  * Action: admin_post_folder_auditor_plugins_root_bulk
    56 
    57 
    58  */
    59 
    60 
    61 public function handle_plugins_root_bulk() {
    62 
     255private function get_safe_plugin_file( string $file ) {
     256
     257    // Only allow files directly in wp-content/plugins (no subfolders)
     258
     259    $file = trim($file);
     260
     261    // Basic invalid checks
     262
     263    if ($file === '' || strpos($file, '..') !== false || strpbrk($file, "/\\")) {
     264
     265        return '';
     266
     267    }
     268
     269    // Build absolute path and resolve
     270
     271    $abs        = trailingslashit(WP_PLUGIN_DIR) . $file;
     272
     273    $realBase   = realpath(WP_PLUGIN_DIR);
     274
     275    $realTarget = ($abs && file_exists($abs)) ? realpath($abs) : '';
     276
     277    // Must be a readable file inside wp-content/plugins
     278
     279    if ($realBase && $realTarget && strpos($realTarget, $realBase) === 0 && is_file($realTarget) && is_readable($realTarget)) {
     280
     281        return $realTarget;
     282
     283    }
     284
     285    return '';
     286
     287}
     288
     289   
     290
     291public function handle_plugin_file_view_ajax() {
    63292
    64293    if ( ! $this->can_manage() ) {
    65294
    66 
    67         wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    68 
    69 
    70     }
    71 
    72 
    73     // CSRF
    74 
    75 
    76     check_admin_referer( 'fa_plugins_root_bulk' );
    77 
    78 
    79     // Fetch arrays safely
    80 
    81 
    82     $bulk_raw = (array) ( filter_input( INPUT_POST, 'bulk', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
    83 
    84 
    85     $file_raw = (array) ( filter_input( INPUT_POST, 'file', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
    86 
    87 
    88     // Sanitize into new arrays
    89 
    90 
    91     $bulk = array();
    92 
    93 
    94     foreach ( $bulk_raw as $k => $v ) {
    95 
    96 
    97         $bulk[ sanitize_text_field( (string) $k ) ] = sanitize_text_field( (string) $v );
    98 
    99 
    100     }
    101 
    102 
    103     $files = array();
    104 
    105 
    106     foreach ( $file_raw as $k => $v ) {
    107 
    108 
    109         $files[ sanitize_text_field( (string) $k ) ] = sanitize_file_name( (string) $v );
    110 
    111 
    112     }
    113 
    114 
    115     // Tally
    116 
    117 
    118     $deleted = 0;
    119 
    120 
    121     $ignored_added = 0;
    122 
    123 
    124     $ignored_removed = 0;
    125 
    126 
    127     // FS helper (if available)
    128 
    129 
    130     $wp_filesystem = $this->fa_require_filesystem();
    131 
    132 
    133     foreach ( $bulk as $row_key => $choice ) {
    134 
    135 
    136         $file = isset( $files[ $row_key ] ) ? (string) $files[ $row_key ] : '';
    137 
    138 
    139         if ( '' === $file ) { continue; }
    140 
    141 
    142         if ( ! in_array( $choice, array( 'delete', 'ignore', 'include' ), true ) ) {
    143 
    144 
    145             continue;
    146 
    147 
    148         }
    149 
    150 
    151         if ( 'delete' === $choice ) {
    152 
    153 
    154             $abs = $this->get_safe_plugin_file( $file ); // only returns files in wp-content/plugins
    155 
    156 
    157             if ( $abs && is_file( $abs ) ) {
    158 
    159 
    160                 // Prefer WP_Filesystem; fallback to wp_delete_file
    161 
    162 
    163                 $ok = true;
    164 
    165 
    166                 if ( $wp_filesystem && method_exists( $wp_filesystem, 'delete' ) ) {
    167 
    168 
    169                     if ( method_exists( $wp_filesystem, 'chmod' ) ) {
    170 
    171 
    172                         // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
    173 
    174 
    175                         @$wp_filesystem->chmod( $abs, FS_CHMOD_FILE );
    176 
     295        wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'folder-auditor' ) ), 403 );
     296
     297    }
     298
     299    $file = (string) ( filter_input( INPUT_POST, 'file', FILTER_UNSAFE_RAW ) ?? '' );
     300
     301    $file = sanitize_text_field( $file );
     302
     303    if ( ! check_ajax_referer( 'fa_plugin_file_view_' . md5( $file ), '_wpnonce', false ) ) {
     304
     305        wp_send_json_error( array( 'message' => __( 'Invalid or expired nonce.', 'folder-auditor' ) ), 400 );
     306
     307    }
     308
     309    // Use your existing safe resolver inside plugins directory:
     310
     311    $abs = $this->get_safe_plugin_file( $file ); // or your equivalent helper
     312
     313    if ( ! $abs ) {
     314
     315        wp_send_json_error( array( 'message' => __( 'Invalid file.', 'folder-auditor' ) ), 400 );
     316
     317    }
     318
     319    $fs = $this->fa_require_filesystem();
     320
     321    $contents = $fs && method_exists( $fs, 'get_contents' )
     322
     323        ? $fs->get_contents( $abs )
     324
     325        : @file_get_contents( $abs );
     326
     327    if ( false === $contents ) {
     328
     329        wp_send_json_error( array( 'message' => __( 'Unable to read file (permissions?).', 'folder-auditor' ) ), 500 );
     330
     331    }
     332
     333    $max = 200 * 1024;
     334
     335    $truncated = false;
     336
     337    if ( strlen( $contents ) > $max ) {
     338
     339        $contents  = substr( $contents, 0, $max );
     340
     341        $truncated = true;
     342
     343    }
     344
     345    wp_send_json_success( array(
     346
     347        'file'      => $file,
     348
     349        'size'      => (int) @filesize( $abs ),
     350
     351        'mtime'     => (int) @filemtime( $abs ),
     352
     353        'truncated' => $truncated,
     354
     355        'content'   => $contents,
     356
     357    ) );
     358
     359}
     360
     361    // ============================
     362
     363    // Root File Handlers (generic)
     364
     365    // ============================
     366
     367    /**
     368
     369     * Download a single file from the site root (validated via get_safe_root_file()).
     370
     371     *
     372
     373     * Nonce: 'folder_auditor_file_download_{file}'
     374
     375     * POST:  file
     376
     377     */
     378
     379    public function handle_file_download() {
     380
     381        if ( ! $this->can_manage() ) {
     382
     383            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     384
     385        }
     386
     387        $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
     388
     389        check_admin_referer( 'folder_auditor_file_download_' . $file );
     390
     391        $abs = $this->get_safe_root_file( $file );
     392
     393        if ( ! $abs ) {
     394
     395            wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
     396
     397        }
     398
     399        // Clean output buffers to prevent corruption
     400
     401        if ( function_exists( 'ob_get_level' ) ) {
     402
     403            while ( ob_get_level() ) { ob_end_clean(); }
     404
     405        }
     406
     407        $wp_filesystem = $this->fa_require_filesystem();
     408
     409        $size          = is_file( $abs ) ? (int) filesize( $abs ) : 0;
     410
     411        $bytes         = $wp_filesystem->get_contents( $abs );
     412
     413        $download_name = sanitize_file_name( basename( $abs ) );
     414
     415        nocache_headers();
     416
     417        header( 'Content-Type: application/octet-stream' );
     418
     419        if ( $size > 0 ) {
     420
     421            header( 'Content-Length: ' . $size );
     422
     423        }
     424
     425        header( 'Content-Disposition: attachment; filename="' . rawurlencode( $download_name ) . '"' );
     426
     427        if ( false !== $bytes ) {
     428
     429            echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
     430
     431        }
     432
     433        exit;
     434
     435    }
     436
     437    /**
     438
     439     * Delete a single file from the site root (validated via get_safe_root_file()).
     440
     441     *
     442
     443     * Nonce: 'folder_auditor_file_delete_{file}'
     444
     445     * POST:  file
     446
     447     */
     448
     449    public function handle_file_delete() {
     450
     451        if ( ! $this->can_manage() ) {
     452
     453            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     454
     455        }
     456
     457        $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
     458
     459        check_admin_referer( 'folder_auditor_file_delete_' . $file );
     460
     461        $abs = $this->get_safe_root_file( $file );
     462
     463        if ( ! $abs ) {
     464
     465            wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
     466
     467        }
     468
     469        $wp_filesystem = $this->fa_require_filesystem();
     470
     471        $ok = true;
     472
     473        if ( method_exists( $wp_filesystem, 'delete' ) ) {
     474
     475            $ok = $wp_filesystem->delete( $abs );
     476
     477        } else {
     478
     479            // Safe fallback for single files only
     480
     481            $ok = (bool) wp_delete_file( $abs );
     482
     483        }
     484
     485        if ( ! $ok ) {
     486
     487            wp_die( esc_html__( 'Failed to delete the file (permissions?).', 'folder-auditor' ) );
     488
     489        }
     490
     491        // Redirect back to the Guard Dog Security page (keep ‘plugins’ tab)
     492
     493        $redirect = add_query_arg(
     494
     495            array(
     496
     497                'page'                   => self::MENU_SLUG,
     498
     499                'tab'                    => 'plugins',
     500
     501                'folder_auditor_deleted' => rawurlencode( $file ), // reuse notice
     502
     503            ),
     504
     505            admin_url( 'admin.php' )
     506
     507        );
     508
     509        wp_safe_redirect( $redirect );
     510
     511        exit;
     512
     513    }
     514
     515    // ======================
     516
     517    // Plugin List Utilities
     518
     519    // ======================
     520
     521    /**
     522
     523     * Retrieve installed plugins (mirrors Plugins screen after filters).
     524
     525     *
     526
     527     * @return array
     528
     529     */
     530
     531    private function get_installed_plugins() : array {
     532
     533        if ( ! function_exists( 'get_plugins' ) ) {
     534
     535            require_once ABSPATH . 'wp-admin/includes/plugin.php';
     536
     537        }
     538
     539        $plugins = get_plugins();
     540
     541        // Match Plugins screen exactly
     542
     543        $plugins = apply_filters( 'all_plugins', $plugins );
     544
     545        return $plugins;
     546
     547    }
     548
     549    /**
     550
     551     * Map of top-level plugin folders in wp-content/plugins → slug => absolute path.
     552
     553     *
     554
     555     * @return array
     556
     557     */
     558
     559    private function get_plugin_folders() : array {
     560
     561        $map = [];
     562
     563        if ( is_dir( WP_PLUGIN_DIR ) && is_readable( WP_PLUGIN_DIR ) ) {
     564
     565            try {
     566
     567                $it = new DirectoryIterator( WP_PLUGIN_DIR );
     568
     569                foreach ( $it as $fileinfo ) {
     570
     571                    if ( $fileinfo->isDot() ) { continue; }
     572
     573                    if ( $fileinfo->isDir() ) {
     574
     575                        $slug = $fileinfo->getFilename();
     576
     577                        $map[ $slug ] = $fileinfo->getPathname();
    177578
    178579                    }
    179580
    180 
    181                     $ok = (bool) $wp_filesystem->delete( $abs, false, 'f' );
    182 
    183 
    184                 } else {
    185 
    186 
    187                     $ok = (bool) wp_delete_file( $abs );
    188 
    189 
    190581                }
    191582
    192 
    193                 if ( $ok ) {
    194 
    195 
    196                     $deleted++;
    197 
    198 
    199                     // If it was ignored, untrack it now
    200 
    201 
    202                     $this->ignore_remove( 'plugins_root', $file );
    203 
     583            } catch ( Exception $e ) {
     584
     585                // Fallback scan if DirectoryIterator fails
     586
     587                $dirs = glob( WP_PLUGIN_DIR . '/*', GLOB_ONLYDIR );
     588
     589                if ( is_array( $dirs ) ) {
     590
     591                    foreach ( $dirs as $dir ) { $map[ basename( $dir ) ] = $dir; }
    204592
    205593                }
    206594
    207 
    208595            }
    209596
    210 
    211         } elseif ( 'ignore' === $choice ) {
    212 
    213 
    214             $this->ignore_add( 'plugins_root', $file );
    215 
    216 
    217             $ignored_added++;
    218 
    219 
    220         } elseif ( 'include' === $choice ) {
    221 
    222 
    223             $this->ignore_remove( 'plugins_root', $file );
    224 
    225 
    226             $ignored_removed++;
    227 
    228 
    229         }
    230 
    231 
    232     }
    233 
    234 
    235     // Redirect back to Plugins tab with a compact summary
    236 
    237 
    238     $args = array(
    239 
    240 
    241         'page'                          => self::MENU_SLUG,
    242 
    243 
    244         'tab'                           => 'plugins',
    245 
    246 
    247         'fa_plugins_root_deleted'       => (int) $deleted,
    248 
    249 
    250         'fa_plugins_root_ignored'       => (int) $ignored_added,
    251 
    252 
    253         'fa_plugins_root_included'      => (int) $ignored_removed,
    254 
    255 
    256         // jump straight to the section
    257 
     597        }
     598
     599        return $map;
     600
     601    }
     602
     603    /**
     604
     605     * Build rows matching the Plugins screen items.
     606
     607     *
     608
     609     * Each row:
     610
     611     * - name        → plugin display name
     612
     613     * - plugin_file → plugin main file (basename)
     614
     615     * - folder_slug → top folder slug ('.' for single-file plugins)
     616
     617     *
     618
     619     * @return array
     620
     621     */
     622
     623    private function build_plugin_rows() : array {
     624
     625        $plugins = $this->get_installed_plugins();
     626
     627        $rows    = [];
     628
     629        foreach ( $plugins as $plugin_basename => $data ) {
     630
     631            $parts = explode( '/', $plugin_basename, 2 );
     632
     633            $slug  = ( count( $parts ) > 1 ) ? $parts[0] : '.'; // '.' = single-file plugin
     634
     635            $rows[] = [
     636
     637                'name'        => isset( $data['Name'] ) ? $data['Name'] : $plugin_basename,
     638
     639                'plugin_file' => $plugin_basename,
     640
     641                'folder_slug' => $slug,
     642
     643            ];
     644
     645        }
     646
     647        usort(
     648
     649            $rows,
     650
     651            function( $a, $b ) {
     652
     653                return strcasecmp( $a['name'], $b['name'] );
     654
     655            }
    258656
    259657        );
    260658
    261 
    262     $args['#'] = 'plugins-root-files'; // anchor
    263 
    264 
    265     wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
    266 
    267 
    268     exit;
    269 
     659        return $rows;
     660
     661    }
     662
     663    /**
     664
     665     * Generic lister: returns two arrays [ $folders, $files ] for a given path.
     666
     667     *
     668
     669     * @param string $path
     670
     671     * @return array{0: string[], 1: string[]}
     672
     673     */
     674
     675    private function list_top_level( $path ) : array {
     676
     677        $folders = [];
     678
     679        $files   = [];
     680
     681        if ( is_dir( $path ) && is_readable( $path ) ) {
     682
     683            try {
     684
     685                $it = new DirectoryIterator( $path );
     686
     687                foreach ( $it as $fi ) {
     688
     689                    if ( $fi->isDot() ) { continue; }
     690
     691                    if ( $fi->isDir() ) {
     692
     693                        $folders[] = $fi->getFilename();
     694
     695                    } elseif ( $fi->isFile() ) {
     696
     697                        $files[] = $fi->getFilename();
     698
     699                    }
     700
     701                }
     702
     703            } catch ( Exception $e ) {
     704
     705                // Silent fallback; return what we have
     706
     707            }
     708
     709        }
     710
     711        sort( $folders, SORT_NATURAL | SORT_FLAG_CASE );
     712
     713        sort( $files,   SORT_NATURAL | SORT_FLAG_CASE );
     714
     715        return [ $folders, $files ];
     716
     717    }
     718
     719    // ==========================
     720
     721    // Plugin Folder ZIP/Deletion
     722
     723    // ==========================
     724
     725    /**
     726
     727     * Download a plugin folder as ZIP (triggered via admin-post.php).
     728
     729     *
     730
     731     * Nonce: 'folder_auditor_download_{slug}'
     732
     733     * POST:  slug
     734
     735     */
     736
     737    public function handle_download() {
     738
     739        if ( ! $this->can_manage() ) {
     740
     741            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     742
     743        }
     744
     745        $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
     746
     747        check_admin_referer( 'folder_auditor_download_' . $slug );
     748
     749        $folder = $this->get_safe_folder_path( $slug );
     750
     751        if ( ! $folder ) {
     752
     753            wp_die( esc_html__( 'Invalid folder.', 'folder-auditor' ) );
     754
     755        }
     756
     757        if ( ! class_exists( 'ZipArchive' ) ) {
     758
     759            wp_die( esc_html__( 'ZipArchive is not available on this server.', 'folder-auditor' ) );
     760
     761        }
     762
     763        // Build ZIP in temp (stream from disk to avoid memory spikes)
     764
     765        $zip_file = wp_tempnam( 'folder-auditor-' . $slug . '-' );
     766
     767        if ( ! $zip_file ) {
     768
     769            wp_die( esc_html__( 'Could not create a temporary ZIP file.', 'folder-auditor' ) );
     770
     771        }
     772
     773        $zip = new ZipArchive();
     774
     775        if ( true !== $zip->open( $zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
     776
     777            wp_delete_file( $zip_file ); // safe cleanup
     778
     779            wp_die( esc_html__( 'Failed to build ZIP.', 'folder-auditor' ) );
     780
     781        }
     782
     783        $base_len = strlen( trailingslashit( $folder ) );
     784
     785        $rii = new RecursiveIteratorIterator(
     786
     787            new RecursiveDirectoryIterator( $folder, FilesystemIterator::SKIP_DOTS )
     788
     789        );
     790
     791        foreach ( $rii as $fi ) {
     792
     793            if ( $fi->isFile() ) {
     794
     795                $full = $fi->getPathname();
     796
     797                $rel  = substr( $full, $base_len );
     798
     799                $zip->addFile( $full, $rel );
     800
     801            }
     802
     803        }
     804
     805        $zip->close();
     806
     807        $download_name = sanitize_file_name( $slug . '.zip' );
     808
     809        // Clean any open buffers (avoid corruption)
     810
     811        if ( function_exists( 'ob_get_level' ) ) {
     812
     813            while ( ob_get_level() ) { ob_end_clean(); }
     814
     815        }
     816
     817        $wp_filesystem = $this->fa_require_filesystem();
     818
     819        $size          = is_file( $zip_file ) ? (int) filesize( $zip_file ) : 0;
     820
     821        $bytes         = $wp_filesystem->get_contents( $zip_file );
     822
     823        nocache_headers();
     824
     825        header( 'Content-Type: application/zip' );
     826
     827        if ( $size > 0 ) {
     828
     829            header( 'Content-Length: ' . $size );
     830
     831        }
     832
     833        header( 'Content-Disposition: attachment; filename="' . rawurlencode( $download_name ) . '"' );
     834
     835        if ( false !== $bytes ) {
     836
     837            echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
     838
     839        }
     840
     841        wp_delete_file( $zip_file ); // remove temp file (no unlink())
     842
     843        exit;
     844
     845    }
     846
     847    /**
     848
     849     * Delete a plugin folder (recursive).
     850
     851     *
     852
     853     * Nonce: 'folder_auditor_delete_{slug}'
     854
     855     * POST:  slug
     856
     857     */
     858
     859    public function handle_delete() {
     860
     861        if ( ! $this->can_manage() ) {
     862
     863            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     864
     865        }
     866
     867        $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
     868
     869        check_admin_referer( 'folder_auditor_delete_' . $slug );
     870
     871        $folder = $this->get_safe_folder_path( $slug );
     872
     873        if ( ! $folder ) {
     874
     875            wp_die( esc_html__( 'Invalid folder.', 'folder-auditor' ) );
     876
     877        }
     878
     879        // Require Filesystem API and use it for recursive removal (no rmdir()/unlink() fallbacks)
     880
     881        $wp_filesystem = $this->fa_require_filesystem();
     882
     883        $ok            = $wp_filesystem->delete( $folder, true ); // recursive
     884
     885        if ( ! $ok ) {
     886
     887            wp_die( esc_html__( 'Failed to delete the folder (permissions?).', 'folder-auditor' ) );
     888
     889        }
     890
     891        // Redirect back to the auditor page with a success flag
     892
     893        $redirect = add_query_arg(
     894
     895            array(
     896
     897                'page'                   => self::MENU_SLUG,
     898
     899                'folder_auditor_deleted' => rawurlencode( $slug ),
     900
     901                // keep tab context if present
     902
     903                'tab'                    => isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'plugins',
     904
     905            ),
     906
     907            admin_url( 'admin.php' )
     908
     909        );
     910
     911        wp_safe_redirect( $redirect );
     912
     913        exit;
     914
     915    }
    270916
    271917}
    272 
    273 
    274    
    275 
    276 
    277 private function get_safe_plugin_file( string $file ) {
    278 
    279 
    280     // Only allow files directly in wp-content/plugins (no subfolders)
    281 
    282 
    283     $file = trim($file);
    284 
    285 
    286     // Basic invalid checks
    287 
    288 
    289     if ($file === '' || strpos($file, '..') !== false || strpbrk($file, "/\\")) {
    290 
    291 
    292         return '';
    293 
    294 
    295     }
    296 
    297 
    298     // Build absolute path and resolve
    299 
    300 
    301     $abs        = trailingslashit(WP_PLUGIN_DIR) . $file;
    302 
    303 
    304     $realBase   = realpath(WP_PLUGIN_DIR);
    305 
    306 
    307     $realTarget = ($abs && file_exists($abs)) ? realpath($abs) : '';
    308 
    309 
    310     // Must be a readable file inside wp-content/plugins
    311 
    312 
    313     if ($realBase && $realTarget && strpos($realTarget, $realBase) === 0 && is_file($realTarget) && is_readable($realTarget)) {
    314 
    315 
    316         return $realTarget;
    317 
    318 
    319     }
    320 
    321 
    322     return '';
    323 
    324 
    325 }
    326 
    327 
    328    
    329 
    330 
    331 public function handle_plugin_file_view_ajax() {
    332 
    333 
    334     if ( ! $this->can_manage() ) {
    335 
    336 
    337         wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'folder-auditor' ) ), 403 );
    338 
    339 
    340     }
    341 
    342 
    343     $file = (string) ( filter_input( INPUT_POST, 'file', FILTER_UNSAFE_RAW ) ?? '' );
    344 
    345 
    346     $file = sanitize_text_field( $file );
    347 
    348 
    349     if ( ! check_ajax_referer( 'fa_plugin_file_view_' . md5( $file ), '_wpnonce', false ) ) {
    350 
    351 
    352         wp_send_json_error( array( 'message' => __( 'Invalid or expired nonce.', 'folder-auditor' ) ), 400 );
    353 
    354 
    355     }
    356 
    357 
    358     // Use your existing safe resolver inside plugins directory:
    359 
    360 
    361     $abs = $this->get_safe_plugin_file( $file ); // or your equivalent helper
    362 
    363 
    364     if ( ! $abs ) {
    365 
    366 
    367         wp_send_json_error( array( 'message' => __( 'Invalid file.', 'folder-auditor' ) ), 400 );
    368 
    369 
    370     }
    371 
    372 
    373     $fs = $this->fa_require_filesystem();
    374 
    375 
    376     $contents = $fs && method_exists( $fs, 'get_contents' )
    377 
    378 
    379         ? $fs->get_contents( $abs )
    380 
    381 
    382         : @file_get_contents( $abs );
    383 
    384 
    385     if ( false === $contents ) {
    386 
    387 
    388         wp_send_json_error( array( 'message' => __( 'Unable to read file (permissions?).', 'folder-auditor' ) ), 500 );
    389 
    390 
    391     }
    392 
    393 
    394     $max = 200 * 1024;
    395 
    396 
    397     $truncated = false;
    398 
    399 
    400     if ( strlen( $contents ) > $max ) {
    401 
    402 
    403         $contents  = substr( $contents, 0, $max );
    404 
    405 
    406         $truncated = true;
    407 
    408 
    409     }
    410 
    411 
    412     wp_send_json_success( array(
    413 
    414 
    415         'file'      => $file,
    416 
    417 
    418         'size'      => (int) @filesize( $abs ),
    419 
    420 
    421         'mtime'     => (int) @filemtime( $abs ),
    422 
    423 
    424         'truncated' => $truncated,
    425 
    426 
    427         'content'   => $contents,
    428 
    429 
    430     ) );
    431 
    432 
    433 }
    434 
    435 
    436     // ============================
    437 
    438 
    439     // Root File Handlers (generic)
    440 
    441 
    442     // ============================
    443 
    444 
    445     /**
    446 
    447 
    448      * Download a single file from the site root (validated via get_safe_root_file()).
    449 
    450 
    451      *
    452 
    453 
    454      * Nonce: 'folder_auditor_file_download_{file}'
    455 
    456 
    457      * POST:  file
    458 
    459 
    460      */
    461 
    462 
    463     public function handle_file_download() {
    464 
    465 
    466         if ( ! $this->can_manage() ) {
    467 
    468 
    469             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    470 
    471 
    472         }
    473 
    474 
    475         $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
    476 
    477 
    478         check_admin_referer( 'folder_auditor_file_download_' . $file );
    479 
    480 
    481         $abs = $this->get_safe_root_file( $file );
    482 
    483 
    484         if ( ! $abs ) {
    485 
    486 
    487             wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
    488 
    489 
    490         }
    491 
    492 
    493         // Clean output buffers to prevent corruption
    494 
    495 
    496         if ( function_exists( 'ob_get_level' ) ) {
    497 
    498 
    499             while ( ob_get_level() ) { ob_end_clean(); }
    500 
    501 
    502         }
    503 
    504 
    505         $wp_filesystem = $this->fa_require_filesystem();
    506 
    507 
    508         $size          = is_file( $abs ) ? (int) filesize( $abs ) : 0;
    509 
    510 
    511         $bytes         = $wp_filesystem->get_contents( $abs );
    512 
    513 
    514         $download_name = sanitize_file_name( basename( $abs ) );
    515 
    516 
    517         nocache_headers();
    518 
    519 
    520         header( 'Content-Type: application/octet-stream' );
    521 
    522 
    523         if ( $size > 0 ) {
    524 
    525 
    526             header( 'Content-Length: ' . $size );
    527 
    528 
    529         }
    530 
    531 
    532         header( 'Content-Disposition: attachment; filename="' . rawurlencode( $download_name ) . '"' );
    533 
    534 
    535         if ( false !== $bytes ) {
    536 
    537 
    538             echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
    539 
    540 
    541         }
    542 
    543 
    544         exit;
    545 
    546 
    547     }
    548 
    549 
    550     /**
    551 
    552 
    553      * Delete a single file from the site root (validated via get_safe_root_file()).
    554 
    555 
    556      *
    557 
    558 
    559      * Nonce: 'folder_auditor_file_delete_{file}'
    560 
    561 
    562      * POST:  file
    563 
    564 
    565      */
    566 
    567 
    568     public function handle_file_delete() {
    569 
    570 
    571         if ( ! $this->can_manage() ) {
    572 
    573 
    574             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    575 
    576 
    577         }
    578 
    579 
    580         $file = isset( $_POST['file'] ) ? sanitize_text_field( wp_unslash( $_POST['file'] ) ) : '';
    581 
    582 
    583         check_admin_referer( 'folder_auditor_file_delete_' . $file );
    584 
    585 
    586         $abs = $this->get_safe_root_file( $file );
    587 
    588 
    589         if ( ! $abs ) {
    590 
    591 
    592             wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
    593 
    594 
    595         }
    596 
    597 
    598         $wp_filesystem = $this->fa_require_filesystem();
    599 
    600 
    601         $ok = true;
    602 
    603 
    604         if ( method_exists( $wp_filesystem, 'delete' ) ) {
    605 
    606 
    607             $ok = $wp_filesystem->delete( $abs );
    608 
    609 
    610         } else {
    611 
    612 
    613             // Safe fallback for single files only
    614 
    615 
    616             $ok = (bool) wp_delete_file( $abs );
    617 
    618 
    619         }
    620 
    621 
    622         if ( ! $ok ) {
    623 
    624 
    625             wp_die( esc_html__( 'Failed to delete the file (permissions?).', 'folder-auditor' ) );
    626 
    627 
    628         }
    629 
    630 
    631         // Redirect back to the Guard Dog Security page (keep ‘plugins’ tab)
    632 
    633 
    634         $redirect = add_query_arg(
    635 
    636 
    637             array(
    638 
    639 
    640                 'page'                   => self::MENU_SLUG,
    641 
    642 
    643                 'tab'                    => 'plugins',
    644 
    645 
    646                 'folder_auditor_deleted' => rawurlencode( $file ), // reuse notice
    647 
    648 
    649             ),
    650 
    651 
    652             admin_url( 'admin.php' )
    653 
    654 
    655         );
    656 
    657 
    658         wp_safe_redirect( $redirect );
    659 
    660 
    661         exit;
    662 
    663 
    664     }
    665 
    666 
    667     // ======================
    668 
    669 
    670     // Plugin List Utilities
    671 
    672 
    673     // ======================
    674 
    675 
    676     /**
    677 
    678 
    679      * Retrieve installed plugins (mirrors Plugins screen after filters).
    680 
    681 
    682      *
    683 
    684 
    685      * @return array
    686 
    687 
    688      */
    689 
    690 
    691     private function get_installed_plugins() : array {
    692 
    693 
    694         if ( ! function_exists( 'get_plugins' ) ) {
    695 
    696 
    697             require_once ABSPATH . 'wp-admin/includes/plugin.php';
    698 
    699 
    700         }
    701 
    702 
    703         $plugins = get_plugins();
    704 
    705 
    706         // Match Plugins screen exactly
    707 
    708 
    709         $plugins = apply_filters( 'all_plugins', $plugins );
    710 
    711 
    712         return $plugins;
    713 
    714 
    715     }
    716 
    717 
    718     /**
    719 
    720 
    721      * Map of top-level plugin folders in wp-content/plugins → slug => absolute path.
    722 
    723 
    724      *
    725 
    726 
    727      * @return array
    728 
    729 
    730      */
    731 
    732 
    733     private function get_plugin_folders() : array {
    734 
    735 
    736         $map = [];
    737 
    738 
    739         if ( is_dir( WP_PLUGIN_DIR ) && is_readable( WP_PLUGIN_DIR ) ) {
    740 
    741 
    742             try {
    743 
    744 
    745                 $it = new DirectoryIterator( WP_PLUGIN_DIR );
    746 
    747 
    748                 foreach ( $it as $fileinfo ) {
    749 
    750 
    751                     if ( $fileinfo->isDot() ) { continue; }
    752 
    753 
    754                     if ( $fileinfo->isDir() ) {
    755 
    756 
    757                         $slug = $fileinfo->getFilename();
    758 
    759 
    760                         $map[ $slug ] = $fileinfo->getPathname();
    761 
    762 
    763                     }
    764 
    765 
    766                 }
    767 
    768 
    769             } catch ( Exception $e ) {
    770 
    771 
    772                 // Fallback scan if DirectoryIterator fails
    773 
    774 
    775                 $dirs = glob( WP_PLUGIN_DIR . '/*', GLOB_ONLYDIR );
    776 
    777 
    778                 if ( is_array( $dirs ) ) {
    779 
    780 
    781                     foreach ( $dirs as $dir ) { $map[ basename( $dir ) ] = $dir; }
    782 
    783 
    784                 }
    785 
    786 
    787             }
    788 
    789 
    790         }
    791 
    792 
    793         return $map;
    794 
    795 
    796     }
    797 
    798 
    799     /**
    800 
    801 
    802      * Build rows matching the Plugins screen items.
    803 
    804 
    805      *
    806 
    807 
    808      * Each row:
    809 
    810 
    811      * - name        → plugin display name
    812 
    813 
    814      * - plugin_file → plugin main file (basename)
    815 
    816 
    817      * - folder_slug → top folder slug ('.' for single-file plugins)
    818 
    819 
    820      *
    821 
    822 
    823      * @return array
    824 
    825 
    826      */
    827 
    828 
    829     private function build_plugin_rows() : array {
    830 
    831 
    832         $plugins = $this->get_installed_plugins();
    833 
    834 
    835         $rows    = [];
    836 
    837 
    838         foreach ( $plugins as $plugin_basename => $data ) {
    839 
    840 
    841             $parts = explode( '/', $plugin_basename, 2 );
    842 
    843 
    844             $slug  = ( count( $parts ) > 1 ) ? $parts[0] : '.'; // '.' = single-file plugin
    845 
    846 
    847             $rows[] = [
    848 
    849 
    850                 'name'        => isset( $data['Name'] ) ? $data['Name'] : $plugin_basename,
    851 
    852 
    853                 'plugin_file' => $plugin_basename,
    854 
    855 
    856                 'folder_slug' => $slug,
    857 
    858 
    859             ];
    860 
    861 
    862         }
    863 
    864 
    865         usort(
    866 
    867 
    868             $rows,
    869 
    870 
    871             function( $a, $b ) {
    872 
    873 
    874                 return strcasecmp( $a['name'], $b['name'] );
    875 
    876 
    877             }
    878 
    879 
    880         );
    881 
    882 
    883         return $rows;
    884 
    885 
    886     }
    887 
    888 
    889     /**
    890 
    891 
    892      * Generic lister: returns two arrays [ $folders, $files ] for a given path.
    893 
    894 
    895      *
    896 
    897 
    898      * @param string $path
    899 
    900 
    901      * @return array{0: string[], 1: string[]}
    902 
    903 
    904      */
    905 
    906 
    907     private function list_top_level( $path ) : array {
    908 
    909 
    910         $folders = [];
    911 
    912 
    913         $files   = [];
    914 
    915 
    916         if ( is_dir( $path ) && is_readable( $path ) ) {
    917 
    918 
    919             try {
    920 
    921 
    922                 $it = new DirectoryIterator( $path );
    923 
    924 
    925                 foreach ( $it as $fi ) {
    926 
    927 
    928                     if ( $fi->isDot() ) { continue; }
    929 
    930 
    931                     if ( $fi->isDir() ) {
    932 
    933 
    934                         $folders[] = $fi->getFilename();
    935 
    936 
    937                     } elseif ( $fi->isFile() ) {
    938 
    939 
    940                         $files[] = $fi->getFilename();
    941 
    942 
    943                     }
    944 
    945 
    946                 }
    947 
    948 
    949             } catch ( Exception $e ) {
    950 
    951 
    952                 // Silent fallback; return what we have
    953 
    954 
    955             }
    956 
    957 
    958         }
    959 
    960 
    961         sort( $folders, SORT_NATURAL | SORT_FLAG_CASE );
    962 
    963 
    964         sort( $files,   SORT_NATURAL | SORT_FLAG_CASE );
    965 
    966 
    967         return [ $folders, $files ];
    968 
    969 
    970     }
    971 
    972 
    973     // ==========================
    974 
    975 
    976     // Plugin Folder ZIP/Deletion
    977 
    978 
    979     // ==========================
    980 
    981 
    982     /**
    983 
    984 
    985      * Download a plugin folder as ZIP (triggered via admin-post.php).
    986 
    987 
    988      *
    989 
    990 
    991      * Nonce: 'folder_auditor_download_{slug}'
    992 
    993 
    994      * POST:  slug
    995 
    996 
    997      */
    998 
    999 
    1000     public function handle_download() {
    1001 
    1002 
    1003         if ( ! $this->can_manage() ) {
    1004 
    1005 
    1006             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    1007 
    1008 
    1009         }
    1010 
    1011 
    1012         $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
    1013 
    1014 
    1015         check_admin_referer( 'folder_auditor_download_' . $slug );
    1016 
    1017 
    1018         $folder = $this->get_safe_folder_path( $slug );
    1019 
    1020 
    1021         if ( ! $folder ) {
    1022 
    1023 
    1024             wp_die( esc_html__( 'Invalid folder.', 'folder-auditor' ) );
    1025 
    1026 
    1027         }
    1028 
    1029 
    1030         if ( ! class_exists( 'ZipArchive' ) ) {
    1031 
    1032 
    1033             wp_die( esc_html__( 'ZipArchive is not available on this server.', 'folder-auditor' ) );
    1034 
    1035 
    1036         }
    1037 
    1038 
    1039         // Build ZIP in temp (stream from disk to avoid memory spikes)
    1040 
    1041 
    1042         $zip_file = wp_tempnam( 'folder-auditor-' . $slug . '-' );
    1043 
    1044 
    1045         if ( ! $zip_file ) {
    1046 
    1047 
    1048             wp_die( esc_html__( 'Could not create a temporary ZIP file.', 'folder-auditor' ) );
    1049 
    1050 
    1051         }
    1052 
    1053 
    1054         $zip = new ZipArchive();
    1055 
    1056 
    1057         if ( true !== $zip->open( $zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
    1058 
    1059 
    1060             wp_delete_file( $zip_file ); // safe cleanup
    1061 
    1062 
    1063             wp_die( esc_html__( 'Failed to build ZIP.', 'folder-auditor' ) );
    1064 
    1065 
    1066         }
    1067 
    1068 
    1069         $base_len = strlen( trailingslashit( $folder ) );
    1070 
    1071 
    1072         $rii = new RecursiveIteratorIterator(
    1073 
    1074 
    1075             new RecursiveDirectoryIterator( $folder, FilesystemIterator::SKIP_DOTS )
    1076 
    1077 
    1078         );
    1079 
    1080 
    1081         foreach ( $rii as $fi ) {
    1082 
    1083 
    1084             if ( $fi->isFile() ) {
    1085 
    1086 
    1087                 $full = $fi->getPathname();
    1088 
    1089 
    1090                 $rel  = substr( $full, $base_len );
    1091 
    1092 
    1093                 $zip->addFile( $full, $rel );
    1094 
    1095 
    1096             }
    1097 
    1098 
    1099         }
    1100 
    1101 
    1102         $zip->close();
    1103 
    1104 
    1105         $download_name = sanitize_file_name( $slug . '.zip' );
    1106 
    1107 
    1108         // Clean any open buffers (avoid corruption)
    1109 
    1110 
    1111         if ( function_exists( 'ob_get_level' ) ) {
    1112 
    1113 
    1114             while ( ob_get_level() ) { ob_end_clean(); }
    1115 
    1116 
    1117         }
    1118 
    1119 
    1120         $wp_filesystem = $this->fa_require_filesystem();
    1121 
    1122 
    1123         $size          = is_file( $zip_file ) ? (int) filesize( $zip_file ) : 0;
    1124 
    1125 
    1126         $bytes         = $wp_filesystem->get_contents( $zip_file );
    1127 
    1128 
    1129         nocache_headers();
    1130 
    1131 
    1132         header( 'Content-Type: application/zip' );
    1133 
    1134 
    1135         if ( $size > 0 ) {
    1136 
    1137 
    1138             header( 'Content-Length: ' . $size );
    1139 
    1140 
    1141         }
    1142 
    1143 
    1144         header( 'Content-Disposition: attachment; filename="' . rawurlencode( $download_name ) . '"' );
    1145 
    1146 
    1147         if ( false !== $bytes ) {
    1148 
    1149 
    1150             echo $bytes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
    1151 
    1152 
    1153         }
    1154 
    1155 
    1156         wp_delete_file( $zip_file ); // remove temp file (no unlink())
    1157 
    1158 
    1159         exit;
    1160 
    1161 
    1162     }
    1163 
    1164 
    1165     /**
    1166 
    1167 
    1168      * Delete a plugin folder (recursive).
    1169 
    1170 
    1171      *
    1172 
    1173 
    1174      * Nonce: 'folder_auditor_delete_{slug}'
    1175 
    1176 
    1177      * POST:  slug
    1178 
    1179 
    1180      */
    1181 
    1182 
    1183     public function handle_delete() {
    1184 
    1185 
    1186         if ( ! $this->can_manage() ) {
    1187 
    1188 
    1189             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    1190 
    1191 
    1192         }
    1193 
    1194 
    1195         $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
    1196 
    1197 
    1198         check_admin_referer( 'folder_auditor_delete_' . $slug );
    1199 
    1200 
    1201         $folder = $this->get_safe_folder_path( $slug );
    1202 
    1203 
    1204         if ( ! $folder ) {
    1205 
    1206 
    1207             wp_die( esc_html__( 'Invalid folder.', 'folder-auditor' ) );
    1208 
    1209 
    1210         }
    1211 
    1212 
    1213         // Require Filesystem API and use it for recursive removal (no rmdir()/unlink() fallbacks)
    1214 
    1215 
    1216         $wp_filesystem = $this->fa_require_filesystem();
    1217 
    1218 
    1219         $ok            = $wp_filesystem->delete( $folder, true ); // recursive
    1220 
    1221 
    1222         if ( ! $ok ) {
    1223 
    1224 
    1225             wp_die( esc_html__( 'Failed to delete the folder (permissions?).', 'folder-auditor' ) );
    1226 
    1227 
    1228         }
    1229 
    1230 
    1231         // Redirect back to the auditor page with a success flag
    1232 
    1233 
    1234         $redirect = add_query_arg(
    1235 
    1236 
    1237             array(
    1238 
    1239 
    1240                 'page'                   => self::MENU_SLUG,
    1241 
    1242 
    1243                 'folder_auditor_deleted' => rawurlencode( $slug ),
    1244 
    1245 
    1246                 // keep tab context if present
    1247 
    1248 
    1249                 'tab'                    => isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'plugins',
    1250 
    1251 
    1252             ),
    1253 
    1254 
    1255             admin_url( 'admin.php' )
    1256 
    1257 
    1258         );
    1259 
    1260 
    1261         wp_safe_redirect( $redirect );
    1262 
    1263 
    1264         exit;
    1265 
    1266 
    1267     }
    1268 
    1269 
    1270 }
  • folder-auditor/trunk/includes/helpers/lock-system/traits/WPFA_Folder_Locker_Trait_FS.php

    r3369236 r3395335  
    107107/** Exclude wp-content/cache, Uploads exclusions, wp-content exclusions, and ABSPATH (root) exclusions. */
    108108protected static function is_excluded_path( $path ) {
    109     $p = wp_normalize_path( $path );
    110 
    111     $wp_content = wp_normalize_path( WP_CONTENT_DIR );
    112     $abspath   = wp_normalize_path( trailingslashit( ABSPATH ) );
    113 
    114     // 1) Exclude any folder under wp-content with "cache" in the name
    115     $relative_path = str_replace( wp_normalize_path( $wp_content ) . '/', '', $p );
    116    
    117     // Extract the top-level child folder name
    118     $first_segment = explode( '/', $relative_path )[0];
    119    
    120     if ( stripos( $first_segment, 'cache' ) !== false ) {
    121         return true;
    122     }
    123 
    124     // 2) Uploads exclusions (relative to uploads basedir)
    125     $uploads      = wp_get_upload_dir(); // ['basedir'=>...]
    126     $uploads_base = isset( $uploads['basedir'] ) ? wp_normalize_path( $uploads['basedir'] ) : '';
    127     $ex_uploads   = (array) get_option( 'wpfa_never_lock_uploads', array() );
    128     if ( $uploads_base && ! empty( $ex_uploads ) ) {
    129         foreach ( $ex_uploads as $slug ) {
    130             $slug = ltrim( (string) $slug, "/\\" );
    131             if ( '' === $slug ) { continue; }
    132             $abs = wp_normalize_path( trailingslashit( $uploads_base ) . $slug );
    133             if ( $p === $abs || 0 === strpos( $p, trailingslashit( $abs ) ) ) {
    134                 return true;
    135             }
     109    $p = wp_normalize_path( $path );
     110
     111    $wp_content = wp_normalize_path( WP_CONTENT_DIR );
     112    $abspath    = wp_normalize_path( trailingslashit( ABSPATH ) );
     113    $plugins    = wp_normalize_path( WP_PLUGIN_DIR );
     114
     115    // 1) Exclude any folder under wp-content with "cache" in the name
     116    $relative_path = str_replace( $wp_content . '/', '', $p );
     117    $first_segment = explode( '/', $relative_path )[0];
     118    if ( stripos( $first_segment, 'cache' ) !== false ) {
     119        return true;
     120    }
     121
     122    // 2) Uploads exclusions
     123    $uploads      = wp_get_upload_dir();
     124    $uploads_base = isset( $uploads['basedir'] ) ? wp_normalize_path( $uploads['basedir'] ) : '';
     125    $ex_uploads   = (array) get_option( 'wpfa_never_lock_uploads', array() );
     126    if ( $uploads_base && ! empty( $ex_uploads ) ) {
     127        foreach ( $ex_uploads as $slug ) {
     128            $slug = ltrim( (string) $slug, "/\\" );
     129            if ( '' === $slug ) { continue; }
     130            $abs = wp_normalize_path( trailingslashit( $uploads_base ) . $slug );
     131            if ( $p === $abs || strpos( $p, trailingslashit( $abs ) ) === 0 ) {
     132                return true;
     133            }
     134        }
     135    }
     136
     137    // 3) wp-content exclusions
     138    $ex_content = (array) get_option( 'wpfa_never_lock_content', array() );
     139    if ( ! empty( $ex_content ) ) {
     140        foreach ( $ex_content as $slug ) {
     141            $slug = ltrim( (string) $slug, "/\\" );
     142            if ( '' === $slug ) { continue; }
     143            $abs = wp_normalize_path( trailingslashit( $wp_content ) . $slug );
     144            if ( $p === $abs || strpos( $p, trailingslashit( $abs ) ) === 0 ) {
     145                return true;
     146            }
     147        }
     148    }
     149
     150    // 4) ABSPATH exclusions
     151    $ex_root = (array) get_option( 'wpfa_never_lock_root', array() );
     152    if ( ! empty( $ex_root ) ) {
     153        foreach ( $ex_root as $slug ) {
     154            $slug = ltrim( (string) $slug, "/\\" );
     155            if ( '' === $slug ) { continue; }
     156            $abs = wp_normalize_path( $abspath . $slug );
     157            if ( $p === $abs || strpos( $p, trailingslashit( $abs ) ) === 0 ) {
     158                return true;
     159            }
     160        }
     161    }
     162
     163    // 5) 🔥 Plugin exclusions (this was missing!)
     164    $ex_plugins = (array) get_option( 'wpfa_never_lock_plugins', array() );
     165    if ( ! empty( $ex_plugins ) ) {
     166        foreach ( $ex_plugins as $slug ) {
     167            $slug = ltrim( (string) $slug, "/\\" );
     168            if ( '' === $slug ) continue;
     169
     170            $abs = wp_normalize_path( trailingslashit( $plugins ) . $slug );
     171
     172            // Match the plugin folder or anything inside it
     173            if ( $p === $abs || strpos( $p, trailingslashit( $abs ) ) === 0 ) {
     174                return true;
     175            }
     176        }
     177    }
     178
     179    return false;
     180}
     181
     182protected static function maybe_write_htaccess( $fs, $dir, $mode ) {
     183
     184            $ht    = wp_normalize_path( trailingslashit( $dir ) . '.htaccess' );
     185
     186            $rules = "# Folder Auditor - Lock Rules\n"
     187
     188                   . "<IfModule mod_rewrite.c>\nRewriteEngine On\n"
     189
     190                   . "RewriteCond %{REQUEST_METHOD} ^(PUT|DELETE|MOVE|COPY|MKCOL|PROPFIND|PROPPATCH|LOCK|UNLOCK|PATCH)$\n"
     191
     192                   . "RewriteRule ^ - [F,L]\n</IfModule>\n\n"
     193
     194                   . "<FilesMatch \"\\.php(?:\\.\\d+)?$\">\nRequire all denied\n</FilesMatch>\n"
     195
     196                   . "Options -Indexes\n";
     197
     198            if ( 'lock' === $mode ) {
     199
     200                $fs->put_contents( $ht, $rules, FS_CHMOD_FILE );
     201
     202                $fs->chmod( $ht, 0444, false, false );
     203
     204            } else {
     205
     206                if ( $fs->exists( $ht ) ) {
     207
     208                    $fs->chmod( $ht, 0644, false, false );
     209
     210                }
     211
     212            }
     213
    136214        }
    137     }
    138 
    139     // 3) wp-content exclusions (relative to WP_CONTENT_DIR)
    140     $ex_content = (array) get_option( 'wpfa_never_lock_content', array() );
    141     if ( ! empty( $ex_content ) ) {
    142         foreach ( $ex_content as $slug ) {
    143             $slug = ltrim( (string) $slug, "/\\" );
    144             if ( '' === $slug ) { continue; }
    145             $abs = wp_normalize_path( trailingslashit( $wp_content ) . $slug );
    146             if ( $p === $abs || 0 === strpos( $p, trailingslashit( $abs ) ) ) {
    147                 return true;
    148             }
    149         }
    150     }
    151 
    152     // 4) Root (ABSPATH) exclusions — for top-level non-core folders
    153     $ex_root = (array) get_option( 'wpfa_never_lock_root', array() );
    154     if ( ! empty( $ex_root ) ) {
    155         foreach ( $ex_root as $slug ) {
    156             $slug = ltrim( (string) $slug, "/\\" );
    157             if ( '' === $slug ) { continue; }
    158             $abs = wp_normalize_path( $abspath . $slug );
    159             // Only match top-level dir and its subtree
    160             if ( $p === $abs || 0 === strpos( $p, trailingslashit( $abs ) ) ) {
    161                 return true;
    162             }
    163         }
    164     }
    165 
    166     return false;
     215
     216    }
     217
    167218}
    168219
    169 protected static function maybe_write_htaccess( $fs, $dir, $mode ) {
    170 
    171             $ht    = wp_normalize_path( trailingslashit( $dir ) . '.htaccess' );
    172 
    173             $rules = "# Folder Auditor - Lock Rules\n"
    174 
    175                    . "<IfModule mod_rewrite.c>\nRewriteEngine On\n"
    176 
    177                    . "RewriteCond %{REQUEST_METHOD} ^(PUT|DELETE|MOVE|COPY|MKCOL|PROPFIND|PROPPATCH|LOCK|UNLOCK|PATCH)$\n"
    178 
    179                    . "RewriteRule ^ - [F,L]\n</IfModule>\n\n"
    180 
    181                    . "<FilesMatch \"\\.php(?:\\.\\d+)?$\">\nRequire all denied\n</FilesMatch>\n"
    182 
    183                    . "Options -Indexes\n";
    184 
    185             if ( 'lock' === $mode ) {
    186 
    187                 $fs->put_contents( $ht, $rules, FS_CHMOD_FILE );
    188 
    189                 $fs->chmod( $ht, 0444, false, false );
    190 
    191             } else {
    192 
    193                 if ( $fs->exists( $ht ) ) {
    194 
    195                     $fs->chmod( $ht, 0644, false, false );
    196 
    197                 }
    198 
    199             }
    200 
    201         }
    202 
    203     }
    204 
    205 }
    206 
  • folder-auditor/trunk/includes/views/view-plugins.php

    r3381342 r3395335  
    44
    55<?php
     6
     7$never_lock_plugins = (array) get_option( 'wpfa_never_lock_plugins', array() );
    68
    79// Ignore these slugs everywhere (counts + tables)
     
    202204
    203205        <th><?php esc_html_e( 'Actions', 'folder-auditor' ); ?></th>
     206       
     207        <th><?php esc_html_e( 'Lock Status', 'folder-auditor' ); ?></th>
    204208
    205209    </tr>
     
    211215        <?php if ( empty( $plugin_rows ) ) : ?>
    212216
    213             <tr><td colspan="4"><?php esc_html_e( 'No plugins found.', 'folder-auditor' ); ?></td></tr>
     217            <tr><td colspan="5"><?php esc_html_e( 'No plugins found.', 'folder-auditor' ); ?></td></tr>
    214218
    215219        <?php else : foreach ( $plugin_rows as $row ) :
    216220
    217221            $slug = $row['folder_slug'];
     222           
     223    $never_lock_list = isset( $never_lock_plugins ) ? (array) $never_lock_plugins : array();
     224    $is_excluded     = in_array( $slug, $never_lock_list, true );
     225
     226    $toggle_action = $is_excluded
     227        ? 'folder_auditor_plugin_allow_lock'
     228        : 'folder_auditor_plugin_never_lock';
     229
     230    $toggle_nonce = wp_create_nonce( 'fa_plugin_toggle_' . $slug );
     231
    218232
    219233            $is_active = is_plugin_active( $row['plugin_file'] );
     
    390404
    391405                </td>
     406               
     407                        <td>
     408            <?php
     409            // Lock Status toggle button (copy of uploads UI, just plugin-specific action/nonce)
     410            ?>
     411            <form style="display:inline;" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
     412                <input type="hidden" name="action" value="<?php echo esc_attr( $toggle_action ); ?>">
     413                <input type="hidden" name="slug"   value="<?php echo esc_attr( $slug ); ?>">
     414                <input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $toggle_nonce ); ?>">
     415
     416                <button type="submit"
     417                    class="button folder-toggle-button <?php echo $is_excluded ? 'folder-unlocked' : 'folder-locked'; ?>">
     418                    <span class="dashicons <?php echo $is_excluded ? 'dashicons-unlock' : 'dashicons-lock'; ?>"></span>
     419                    <span class="label">
     420                        <?php echo $is_excluded
     421                            ? esc_html__( 'Never Lock', 'folder-auditor' )
     422                            : esc_html__( 'Allow Lock', 'folder-auditor' ); ?>
     423                    </span>
     424                </button>
     425            </form>
     426        </td>
    392427
    393428            </tr>
  • folder-auditor/trunk/readme.txt

    r3395071 r3395335  
    66Tested up to: 6.8
    77Requires PHP: 7.4
    8 Stable tag: 4.9.1
     8Stable tag: 4.9.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    9393== Changelog ==
    9494
     95= 4.9.2 =
     96* Can now exclude single plugins from Site Lock
     97
    9598= 4.9.1 =
    9699* Added new infection patterns to stop false positives
     
    216219== Upgrade Notice ==
    217220
     221= 4.9.2 =
     222* Can now exclude single plugins from Site Lock
     223
    218224= 4.9.1 =
    219225* Added new infection patterns to stop false positives
Note: See TracChangeset for help on using the changeset viewer.