Plugin Directory

Changeset 3381342


Ignore:
Timestamp:
10/20/2025 01:48:45 PM (4 months ago)
Author:
wpfixit
Message:
  • Improved scanner performance
Location:
folder-auditor
Files:
74 added
9 edited

Legend:

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

    r3380575 r3381342  
    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.6
     5 * Version: 4.7
    66 * Author: WP Fix It
    77 * Author URI: https://www.wpfixit.com
  • folder-auditor/trunk/includes/handlers/handler-htaccess.php

    r3374418 r3381342  
    11<?php
    22
    3 
    43/**
    54
    6 
    75 * .htaccess Handlers for Folder Auditor
    86
    9 
    107 *
    118
    12 
    139 * Manages ignore/include, download, and delete operations for .htaccess files.
    1410
    15 
    1611 * Includes bulk operations with nonce & capability checks, plus safe-path resolution.
    1712
    18 
    1913 *
    2014
    21 
    2215 * Note: This trait expects the class to provide fa_require_filesystem()
    2316
    24 
    2517 * (e.g., via WPFA_content_handler_functions).
    2618
    27 
    2819 */
    2920
    30 
    3121if ( ! defined( 'ABSPATH' ) ) { exit; } // No direct access 👮
    3222
    33 
    3423trait WPFA_htaccess_handler_functions {
    3524
    36 
    3725// AJAX: view a single .htaccess file's contents in a modal.
    3826
    39 
    4027// Action: wp_ajax_folder_auditor_htaccess_view
    4128
    42 
    4329public function handle_htaccess_view_ajax() {
    4430
    45 
    4631    if ( ! $this->can_manage() ) {
    4732
    48 
    4933        wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'folder-auditor' ) ), 403 );
    5034
    51 
    5235    }
    5336
    54 
    5537    $rel = (string) ( filter_input( INPUT_POST, 'rel', FILTER_UNSAFE_RAW ) ?? '' );
    5638
    57 
    5839    $rel = sanitize_text_field( $rel );
    5940
    60 
    6141    // Row-specific nonce: 'fa_htaccess_view_' . md5($rel)
    6242
    63 
    6443    if ( ! check_ajax_referer( 'fa_htaccess_view_' . md5( $rel ), '_wpnonce', false ) ) {
    6544
    66 
    6745        wp_send_json_error( array( 'message' => __( 'Invalid or expired nonce.', 'folder-auditor' ) ), 400 );
    6846
    69 
    7047    }
    7148
    72 
    7349    $abs = $this->get_safe_htaccess_file( $rel );
    7450
    75 
    7651    if ( ! $abs ) {
    7752
    78 
    7953        wp_send_json_error( array( 'message' => __( 'Invalid file.', 'folder-auditor' ) ), 400 );
    8054
    81 
    8255    }
    8356
    84 
    8557    // Read via WP_Filesystem if available
    8658
    87 
    8859    $wp_filesystem = $this->fa_require_filesystem();
    8960
    90 
    9161    $contents      = false;
    9262
    93 
    9463    if ( $wp_filesystem && method_exists( $wp_filesystem, 'get_contents' ) ) {
    9564
    96 
    9765        $contents = $wp_filesystem->get_contents( $abs );
    9866
    99 
    10067    } else {
    10168
    102 
    10369        // Fallback
    10470
    105 
    10671        $contents = @file_get_contents( $abs );
    10772
    108 
    10973    }
    11074
    111 
    11275    if ( false === $contents ) {
    11376
    114 
    11577        wp_send_json_error( array( 'message' => __( 'Unable to read file (permissions?).', 'folder-auditor' ) ), 500 );
    11678
    117 
    11879    }
    11980
    120 
    12181    // Safety limits: truncate large files (e.g., 200KB)
    12282
    123 
    12483    $max_bytes = 200 * 1024;
    12584
    126 
    12785    $truncated = false;
    12886
    129 
    13087    if ( strlen( $contents ) > $max_bytes ) {
    13188
    132 
    13389        $contents  = substr( $contents, 0, $max_bytes );
    13490
    135 
    13691        $truncated = true;
    13792
    138 
    13993    }
    14094
    141 
    14295    // Return plain text (do not escape here; consumer will display in <pre>)
    14396
    144 
    14597    wp_send_json_success( array(
    14698
    147 
    14899        'rel'       => $rel,
    149100
    150 
    151101        'size'      => (int) @filesize( $abs ),
    152102
    153 
    154103        'mtime'     => (int) @filemtime( $abs ),
    155104
    156 
    157105        'truncated' => $truncated,
    158106
    159 
    160107        'content'   => $contents,
    161108
    162 
    163109    ) );
    164110
    165 
    166111}
    167112
    168 
    169113   
    170114
    171 
    172115    /**
    173116
    174 
    175117 * Try to delete a single .htaccess file robustly:
    176118
    177 
    178119 * 1) Attempt delete
    179120
    180 
    181121 * 2) If fail: chmod file, try again
    182122
    183 
    184123 * 3) If still fail: chmod parent dir, try again
    185124
    186 
    187125 *
    188126
    189 
    190127 * @param string            $abs_file     Normalized absolute path to .htaccess
    191128
    192 
    193129 * @param WP_Filesystem_Base $fs          Filesystem API (Direct)
    194130
    195 
    196131 * @return bool
    197132
    198 
    199133 */
    200134
    201 
    202135private function try_delete_htaccess( string $abs_file, $fs ) : bool {
    203136
    204 
    205137    $ok = true;
    206138
    207 
    208139    // 1) Straight delete
    209140
    210 
    211141    if ( method_exists( $fs, 'delete' ) ) {
    212142
    213 
    214143        $ok = $fs->delete( $abs_file, false, 'f' );
    215144
    216 
    217145    } else {
    218146
    219 
    220147        $ok = (bool) wp_delete_file( $abs_file );
    221148
    222 
    223149    }
    224150
    225 
    226151    if ( $ok ) { return true; }
    227152
    228 
    229153    // 2) Make file writable and retry
    230154
    231 
    232155    if ( method_exists( $fs, 'chmod' ) ) { @ $fs->chmod( $abs_file, 0644 ); }
    233156
    234 
    235157    if ( method_exists( $fs, 'delete' ) ) {
    236158
    237 
    238159        $ok = $fs->delete( $abs_file, false, 'f' );
    239160
    240 
    241161    } else {
    242162
    243 
    244163        $ok = (bool) wp_delete_file( $abs_file );
    245164
    246 
    247165    }
    248166
    249 
    250167    if ( $ok ) { return true; }
    251168
    252 
    253169    // 3) Loosen parent dir perms (read/execute for owner at least), retry
    254170
    255 
    256171    $parent = wp_normalize_path( dirname( $abs_file ) );
    257172
    258 
    259173    if ( $parent && is_dir( $parent ) && method_exists( $fs, 'chmod' ) ) {
    260174
    261 
    262175        @ $fs->chmod( $parent, 0755 );
    263176
    264 
    265177    }
    266178
    267 
    268179    if ( method_exists( $fs, 'delete' ) ) {
    269180
    270 
    271181        $ok = $fs->delete( $abs_file, false, 'f' );
    272182
    273 
    274183    } else {
    275184
    276 
    277185        $ok = (bool) wp_delete_file( $abs_file );
    278186
    279 
    280187    }
    281188
    282 
    283189    return (bool) $ok;
    284190
    285 
    286191}
    287192
    288 
    289193    /**
    290194
    291 
    292195     * Ignore all discovered .htaccess files (adds every relative path to the ignore set).
    293196
    294 
    295197     *
    296198
    297 
    298199     * Nonce: 'fa_htaccess_ignore_all'
    299200
    300 
    301201     */
    302202
    303 
    304203    public function handle_htaccess_ignore_all() {
    305204
    306 
    307205        if ( ! $this->can_manage() ) {
    308206
    309 
    310207            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    311208
    312 
    313         }
    314 
     209        }
    315210
    316211        check_admin_referer( 'fa_htaccess_ignore_all' );
    317212
    318 
    319213        $base = realpath( ABSPATH );
    320214
    321 
    322215        if ( ! $base || ! is_dir( $base ) ) {
    323216
    324 
    325217            wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '&tab=htaccess' ) );
    326218
    327 
    328219            exit;
    329220
    330 
    331         }
    332 
     221        }
    333222
    334223        // 1) Scan absolute paths
    335224
    336 
    337225        $all_abs = $this->scan_htaccess_for_bulk( $base );
    338226
    339 
    340227        // 2) Convert to view-friendly relative paths
    341228
    342 
    343229        $added = 0;
    344230
    345 
    346231        $total = 0;
    347232
    348 
    349233        $sets = $this->get_ignored(); // Full ignore sets
    350234
    351 
    352235        if ( ! isset( $sets['htaccess'] ) || ! is_array( $sets['htaccess'] ) ) {
    353236
    354 
    355237            $sets['htaccess'] = [];
    356238
    357 
    358         }
    359 
     239        }
    360240
    361241foreach ( (array) $all_abs as $abs ) {
    362242
    363 
    364243    $total++;
    365244
    366 
    367245    // Ensure normalized/contained, then compute rel
    368246
    369 
    370247    $abs_norm = wp_normalize_path( realpath( $abs ) ?: $abs );
    371248
    372 
    373249    if ( strpos( $abs_norm, wp_normalize_path( $base ) ) !== 0 ) { continue; }
    374250
    375 
    376251    $rel = ltrim( str_replace( wp_normalize_path( $base ), '', $abs_norm ), '/\\' );
    377252
    378 
    379253    if ( $rel === '' ) { continue; }
    380254
    381 
    382255    if ( empty( $sets['htaccess'][ $rel ] ) ) {
    383256
    384 
    385257        $sets['htaccess'][ $rel ] = true;
    386258
    387 
    388259        $added++;
    389260
    390 
    391261    }
    392262
    393 
    394263}
    395264
    396 
    397265        // 3) Persist
    398266
    399 
    400267        $this->save_ignored( $sets );
    401268
    402 
    403269        // 4) Back to the tab with a summary
    404270
    405 
    406271        $redirect = add_query_arg(
    407272
    408 
    409273            [
    410274
    411 
    412275                'page'                    => self::MENU_SLUG,
    413276
    414 
    415277                'tab'                     => 'htaccess',
    416278
    417 
    418279                'fa_htaccess_ignored_all' => (int) $added,
    419280
    420 
    421281                'fa_htaccess_ignored_tot' => (int) $total,
    422282
    423 
    424283            ],
    425284
    426 
    427285            admin_url( 'admin.php' )
    428286
    429 
    430287        );
    431288
    432 
    433289        wp_safe_redirect( $redirect );
    434290
    435 
    436291        exit;
    437292
    438 
    439293    }
    440294
    441 
    442295    /**
    443296
    444 
    445297     * Bulk action handler for .htaccess rows (delete | ignore | include).
    446298
    447 
    448299     *
    449300
    450 
    451301     * Nonce: 'fa_htaccess_bulk'
    452302
    453 
    454303     * POST: bulk[row_key]={delete|ignore|include}, rel[row_key]={relative path}
    455304
    456 
    457305     */
    458306
    459 
    460307    public function handle_htaccess_bulk() {
    461308
    462 
    463309        if ( ! $this->can_manage() ) {
    464310
    465 
    466311            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    467312
    468 
    469         }
    470 
     313        }
    471314
    472315        // CSRF (do not touch $_POST['_wpnonce'] directly)
    473316
    474 
    475317        check_admin_referer( 'fa_htaccess_bulk' );
    476318
    477 
    478319// Fetch arrays from POST without touching superglobals (avoids WPCS warnings).
    479320
    480 
    481321$bulk_raw = (array) ( filter_input( INPUT_POST, 'bulk', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
    482322
    483 
    484323$rels_raw = (array) ( filter_input( INPUT_POST, 'rel',  FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ) ?: array() );
    485324
    486 
    487325// Sanitize into new arrays (both keys and values).
    488326
    489 
    490327$bulk = array();
    491328
    492 
    493329foreach ( $bulk_raw as $k => $v ) {
    494330
    495 
    496331    $bulk[ sanitize_text_field( (string) $k ) ] = sanitize_text_field( (string) $v );
    497332
    498 
    499333}
    500334
    501 
    502335$rels = array();
    503336
    504 
    505337foreach ( $rels_raw as $k => $v ) {
    506338
    507 
    508339    $rels[ sanitize_text_field( (string) $k ) ] = sanitize_text_field( (string) $v );
    509340
    510 
    511341}
    512342
    513 
    514343        $deleted         = 0;
    515344
    516 
    517345        $ignored_added   = 0;
    518346
    519 
    520347        $ignored_removed = 0;
    521348
    522 
    523349        $wp_filesystem = $this->fa_require_filesystem();
    524350
    525 
    526351        foreach ( $rels as $row_key => $rel ) {
    527352
    528 
    529353            $choice = isset( $bulk[ $row_key ] ) ? $bulk[ $row_key ] : '';
    530354
    531 
    532355            if ( ! in_array( $choice, array( 'delete', 'ignore', 'include' ), true ) ) {
    533356
    534 
    535357                continue; // skip unknown/no-op
    536358
    537 
    538359            }
    539360
    540 
    541361            if ( 'delete' === $choice ) {
    542362
    543 
    544363                $abs = $this->get_safe_htaccess_file( $rel );
    545364
    546 
    547365                if ( $abs && is_file( $abs ) && basename( $abs ) === '.htaccess' ) {
    548366
    549 
    550367                    // Prefer Filesystem API; fallback to wp_delete_file (no raw unlink/chmod)
    551368
    552 
    553369                    $ok = true;
    554370
    555 
    556371                    if ( method_exists( $wp_filesystem, 'delete' ) ) {
    557372
    558 
    559373                        if ( method_exists( $wp_filesystem, 'chmod' ) ) {
    560374
    561 
    562375                            @$wp_filesystem->chmod( $abs, FS_CHMOD_FILE );
    563376
    564 
    565377                        }
    566378
    567 
    568379                        $ok = $wp_filesystem->delete( $abs, false, 'f' );
    569380
    570 
    571381                    } else {
    572382
    573 
    574383                        $ok = (bool) wp_delete_file( $abs );
    575384
    576 
    577385                    }
    578386
    579 
    580387                    if ( $ok ) {
    581388
    582 
    583389                        $deleted++;
    584390
    585 
    586391                        // Remove from ignore set if it was tracked
    587392
    588 
    589393                        $this->ignore_remove( 'htaccess', $rel );
    590394
    591 
    592395                    }
    593396
    594 
    595397                }
    596398
    597 
    598399            } elseif ( 'ignore' === $choice ) {
    599400
    600 
    601401                $this->ignore_add( 'htaccess', $rel );
    602402
    603 
    604403                $ignored_added++;
    605404
    606 
    607405            } elseif ( 'include' === $choice ) {
    608406
    609 
    610407                $this->ignore_remove( 'htaccess', $rel );
    611408
    612 
    613409                $ignored_removed++;
    614410
    615 
    616411            }
    617412
    618 
    619         }
    620 
     413        }
    621414
    622415        // UX: bounce back with a summary; preserve pagination if present
    623416
    624 
    625417        $args = array(
    626418
    627 
    628419            'page'         => self::MENU_SLUG,
    629420
    630 
    631421            'tab'          => 'htaccess',
    632422
    633 
    634423            'fa_bulk_done' => 1,
    635424
    636 
    637425            'fa_deleted'   => $deleted,
    638426
    639 
    640427            'fa_ignored'   => $ignored_added,
    641428
    642 
    643429            'fa_included'  => $ignored_removed,
    644430
    645 
    646431        );
    647432
    648 
    649433        if ( isset( $_GET['fa_paged'] ) ) {
    650434
    651 
    652435            $args['fa_paged'] = (int) $_GET['fa_paged'];
    653436
    654 
    655         }
    656 
     437        }
    657438
    658439        wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
    659440
    660 
    661441        exit;
    662442
    663 
    664443    }
    665444
    666 
    667445    /**
    668446
    669 
    670447     * Count .htaccess files under ABSPATH (skips common heavy/vendor dirs).
    671448
    672 
    673449     */
    674450
    675 
    676451    private function count_htaccess_files() : int {
    677452
    678 
    679453        $base = realpath( ABSPATH );
    680454
    681 
    682455        if ( ! $base || ! is_dir( $base ) ) { return 0; }
    683456
    684 
    685457        $skip  = array( '.git', '.svn', 'node_modules', 'vendor', 'cache', 'backups', 'backup' );
    686458
    687 
    688459        $count = 0;
    689460
    690 
    691461        try {
    692462
    693 
    694463            $it = new RecursiveIteratorIterator(
    695464
    696 
    697465                new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
    698466
    699 
    700467                RecursiveIteratorIterator::SELF_FIRST
    701468
    702 
    703469            );
    704470
    705 
    706471            foreach ( $it as $fi ) {
    707472
    708 
    709473                if ( $fi->isDir() ) {
    710474
    711 
    712475                    if ( in_array( $fi->getBasename(), $skip, true ) ) { $it->next(); continue; }
    713476
    714 
    715477                } elseif ( $fi->isFile() && $fi->getBasename() === '.htaccess' ) {
    716478
    717 
    718479                    $real = realpath( $fi->getPathname() );
    719480
    720 
    721481                    if ( $real && strpos( $real, $base ) === 0 ) { $count++; }
    722482
    723 
    724483                }
    725484
    726 
    727485            }
    728486
    729 
    730487        } catch ( \Exception $e ) { /* ignore */ }
    731488
    732 
    733489        return $count;
    734490
    735 
    736491    }
    737 
    738 
    739492    /**
    740493
    741 
    742      * Build a summary for the .htaccess metric card (raw vs effective, honoring "ignored").
    743 
     494     * Delete ALL .htaccess files under ABSPATH (2-pass attempt for robustness).
    744495
    745496     *
    746497
    747 
    748      * @return array {
    749 
    750 
    751      *   @type string key
    752 
    753 
    754      *   @type string label
    755 
    756 
    757      *   @type string tab
    758 
    759 
    760      *   @type int    count            Raw total
    761 
    762 
    763      *   @type int    count_effective  Total minus ignored
    764 
    765 
    766      * }
    767 
     498     * Nonce: 'fa_htaccess_delete_all'
    768499
    769500     */
    770501
    771 
    772     private function summary_htaccess() : array {
    773 
    774 
    775         $base    = realpath( ABSPATH );
    776 
    777 
    778         $all_abs = [];
    779 
    780 
    781         $all_rel = [];
    782 
    783 
    784         if ( $base && is_dir( $base ) && is_readable( $base ) ) {
    785 
    786 
    787             try {
    788 
    789 
    790                 $it = new RecursiveIteratorIterator(
    791 
    792 
    793                     new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
    794 
    795 
    796                     RecursiveIteratorIterator::SELF_FIRST
    797 
    798 
    799                 );
    800 
    801 
    802                 $skip = [ '.git', '.svn', 'node_modules', 'vendor', 'cache', 'backups', 'backup' ];
    803 
    804 
    805                 foreach ( $it as $fi ) {
    806 
    807 
    808                     if ( $fi->isDir() ) {
    809 
    810 
    811                         if ( in_array( $fi->getBasename(), $skip, true ) ) { $it->next(); }
    812 
    813 
    814                         continue;
    815 
    816 
    817                     }
    818 
    819 
    820                     if ( ! $fi->isFile() ) { continue; }
    821 
    822 
    823                     if ( $fi->getBasename() !== '.htaccess' ) { continue; }
    824 
    825 
    826                     $abs = realpath( $fi->getPathname() );
    827 
    828 
    829                     if ( ! $abs ) { continue; }
    830 
    831 
    832                     if ( strpos( $abs, $base ) !== 0 ) { continue; }
    833 
    834 
    835                     $all_abs[] = $abs;
    836 
    837 
    838                     $all_rel[] = ltrim( str_replace( $base, '', $abs ), '/\\' ); // same rel format as view
    839 
     502public function handle_htaccess_delete_all() {
     503
     504    if ( ! $this->can_manage() ) { wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) ); }
     505
     506    check_admin_referer( 'fa_htaccess_delete_all' );
     507
     508    if ( function_exists( 'ignore_user_abort' ) ) { ignore_user_abort( true ); }
     509
     510    if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 0 ); } // phpcs:ignore
     511
     512    if ( function_exists( 'ini_set' ) ) { @ini_set( 'max_execution_time', '0' ); } // phpcs:ignore
     513
     514    $abs_root = realpath( ABSPATH );
     515
     516    if ( ! $abs_root || ! is_dir( $abs_root ) || ! is_readable( $abs_root ) ) {
     517
     518        wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '&tab=htaccess' ) );
     519
     520        exit;
     521
     522    }
     523
     524    $root_norm     = wp_normalize_path( $abs_root );
     525
     526    $wp_filesystem = $this->fa_require_filesystem();
     527
     528    $scan          = function() use ( $abs_root ) { return $this->scan_htaccess_for_bulk( $abs_root ); };
     529
     530    $total_deleted = 0;
     531
     532    $total_failed  = 0;
     533
     534    // ---- progress-driven loop with time budget ----
     535
     536    $deadline_sec  = 30;                               // adjust if you want
     537
     538    $deadline_at   = microtime( true ) + $deadline_sec;
     539
     540    $stable_scans  = 0;                                // need 2 consecutive stable scans to stop
     541
     542    $last_left     = null;
     543
     544    while ( microtime( true ) < $deadline_at ) {
     545
     546        $files = $scan();
     547
     548        $left  = count( $files );
     549
     550        // Already stable?
     551
     552        if ( $left === 0 ) { break; }
     553
     554        if ( $last_left !== null && $left === $last_left ) {
     555
     556            $stable_scans++;
     557
     558            if ( $stable_scans >= 2 ) { break; }       // nothing is changing anymore
     559
     560        } else {
     561
     562            $stable_scans = 0;
     563
     564            $last_left    = $left;
     565
     566        }
     567
     568        // Depth-first: longer paths first
     569
     570        usort( $files, static function( $a, $b ){ return strlen( $b ) <=> strlen( $a ); });
     571
     572        $deleted_this_pass = 0;
     573
     574        foreach ( $files as $abs ) {
     575
     576            $real = realpath( $abs );
     577
     578            if ( ! $real ) { $total_failed++; continue; }
     579
     580            $real_norm = wp_normalize_path( $real );
     581
     582            if ( strpos( $real_norm, $root_norm ) !== 0 || basename( $real_norm ) !== '.htaccess' || ! is_file( $real_norm ) ) {
     583
     584                $total_failed++; continue;
     585
     586            }
     587
     588            $ok = $this->try_delete_htaccess( $real_norm, $wp_filesystem );
     589
     590            if ( $ok ) {
     591
     592                $deleted_this_pass++; $total_deleted++;
     593
     594                if ( method_exists( $this, 'ignore_remove' ) ) {
     595
     596                    $rel = ltrim( str_replace( $root_norm, '', $real_norm ), '/\\' );
     597
     598                    $this->ignore_remove( 'htaccess', $rel );
    840599
    841600                }
    842601
    843 
    844             } catch ( \Exception $e ) { /* ignore */ }
    845 
    846 
    847         }
    848 
    849 
    850         $raw_count = count( $all_rel );
    851 
    852 
    853         // Apply ignore set (bucket: 'htaccess'; keys are REL paths like "wp-content/uploads/.htaccess")
    854 
    855 
    856         $ignored        = method_exists( $this, 'get_ignored' ) ? (array) $this->get_ignored() : [];
    857 
    858 
    859         $ignored_keys   = array_keys( (array) ( $ignored['htaccess'] ?? [] ) );
    860 
    861 
    862         $ignored_in_set = count( array_intersect( $all_rel, $ignored_keys ) );
    863 
    864 
    865         $count_effective = max( 0, $raw_count - $ignored_in_set );
    866 
    867 
    868         return [
    869 
    870 
    871             'key'             => 'htaccess',
    872 
    873 
    874             'label'           => __( '.htaccess Files', 'folder-auditor' ),
    875 
    876 
    877             'tab'             => 'htaccess',
    878 
    879 
    880             'count'           => (int) $raw_count,         // raw total for display
    881 
    882 
    883             'count_effective' => (int) $count_effective,   // excludes ignored (for health score)
    884 
    885 
    886         ];
    887 
     602            } else {
     603
     604                $total_failed++;
     605
     606            }
     607
     608        }
     609
     610        // Make sure PHP/FS caches are flushed and give the FS a breath
     611
     612        if ( function_exists( 'clearstatcache' ) ) { clearstatcache( true ); }
     613
     614        usleep( 150000 ); // 150ms; helps on NFS/slow FS
     615
     616        // If nothing deleted in this pass, mark stability (second check happens at loop head)
     617
     618        if ( $deleted_this_pass === 0 ) { /* no-op - stability logic above will catch this */ }
    888619
    889620    }
    890621
     622    $remaining = count( $scan() );
     623
     624    $args = array(
     625
     626        'page'             => self::MENU_SLUG,
     627
     628        'tab'              => 'htaccess',
     629
     630        'fa_htaccess_bulk' => (int) $total_deleted,
     631
     632        'fa_htaccess_fail' => (int) $total_failed,
     633
     634        'fa_htaccess_left' => (int) $remaining,
     635
     636    );
     637
     638    wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
     639
     640    exit;
     641
     642}
    891643
    892644    /**
    893645
    894 
    895      * Delete ALL .htaccess files under ABSPATH (2-pass attempt for robustness).
    896 
     646     * Utility: scan for .htaccess files under a root (absolute paths).
    897647
    898648     *
    899649
    900 
    901      * Nonce: 'fa_htaccess_delete_all'
    902 
     650     * @param string $root
     651
     652     * @return string[] absolute paths
    903653
    904654     */
    905655
    906 
    907 public function handle_htaccess_delete_all() {
    908 
    909 
    910     if ( ! $this->can_manage() ) { wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) ); }
    911 
    912 
    913     check_admin_referer( 'fa_htaccess_delete_all' );
    914 
    915 
    916     if ( function_exists( 'ignore_user_abort' ) ) { ignore_user_abort( true ); }
    917 
    918 
    919     if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 0 ); } // phpcs:ignore
    920 
    921 
    922     if ( function_exists( 'ini_set' ) ) { @ini_set( 'max_execution_time', '0' ); } // phpcs:ignore
    923 
    924 
    925     $abs_root = realpath( ABSPATH );
    926 
    927 
    928     if ( ! $abs_root || ! is_dir( $abs_root ) || ! is_readable( $abs_root ) ) {
    929 
    930 
    931         wp_safe_redirect( admin_url( 'admin.php?page=' . self::MENU_SLUG . '&tab=htaccess' ) );
    932 
     656private function scan_htaccess_for_bulk( $root ) {
     657
     658    $found = [];
     659
     660    if ( ! $root || ! is_dir( $root ) ) { return $found; }
     661
     662    $root_real = realpath( $root );
     663
     664    if ( ! $root_real ) { return $found; }
     665
     666    $root_norm = wp_normalize_path( $root_real );
     667
     668    // Keep this list in sync with other scanners
     669
     670    $skip = array( '.git', '.svn', 'node_modules', 'vendor', 'cache', 'backups', 'backup' );
     671
     672    try {
     673
     674        $dirFlags = FilesystemIterator::SKIP_DOTS;
     675
     676        // Prevent UnexpectedValueException from aborting traversal on unreadable dirs
     677
     678        if ( defined('RecursiveDirectoryIterator::CATCH_GET_CHILD') ) {
     679
     680            $dirFlags |= RecursiveDirectoryIterator::CATCH_GET_CHILD;
     681
     682        }
     683
     684        $it = new RecursiveIteratorIterator(
     685
     686            new RecursiveDirectoryIterator( $root_real, $dirFlags ),
     687
     688            RecursiveIteratorIterator::SELF_FIRST
     689
     690        );
     691
     692        foreach ( $it as $fi ) {
     693
     694            // Skip heavy/vendor dirs early
     695
     696            if ( $fi->isDir() ) {
     697
     698                if ( in_array( $fi->getBasename(), $skip, true ) ) {
     699
     700                    $it->next();
     701
     702                }
     703
     704                continue;
     705
     706            }
     707
     708            if ( ! $fi->isFile() ) { continue; }
     709
     710            if ( $fi->getBasename() !== '.htaccess' ) { continue; }
     711
     712            // Normalize & safety-check (avoid symlink escapes)
     713
     714            $real = realpath( $fi->getPathname() );
     715
     716            if ( ! $real ) { continue; }
     717
     718            $real_norm = wp_normalize_path( $real );
     719
     720            if ( strpos( $real_norm, $root_norm ) !== 0 ) { continue; }
     721
     722            $found[] = $real_norm;
     723
     724        }
     725
     726    } catch ( \Exception $e ) {
     727
     728        // Swallow to keep bulk op resilient
     729
     730    }
     731
     732    return $found;
     733
     734}
     735
     736    /**
     737
     738     * Resolve a safe absolute path for a relative path under ABSPATH,
     739
     740     * restricted strictly to files literally named ".htaccess".
     741
     742     *
     743
     744     * @param string $rel Relative path (e.g., 'wp-content/uploads/.htaccess')
     745
     746     * @return string|false Absolute path if valid and safe, false otherwise
     747
     748     */
     749
     750    private function get_safe_htaccess_file( string $rel ) {
     751
     752        $rel = wp_normalize_path( sanitize_text_field( $rel ) );
     753
     754        // Deny traversal / absolute paths
     755
     756        if ( $rel === '' || strpos( $rel, '..' ) !== false || preg_match( '#^[\\/]|[a-zA-Z]:[\\\\/]#', $rel ) ) {
     757
     758            return false;
     759
     760        }
     761
     762        $base = wp_normalize_path( realpath( ABSPATH ) );
     763
     764        if ( ! $base ) { return false; }
     765
     766        $abs  = wp_normalize_path( $base . '/' . ltrim( $rel, '/\\' ) );
     767
     768        $real = realpath( $abs );
     769
     770        if ( ! $real ) { return false; }
     771
     772        $real = wp_normalize_path( $real );
     773
     774        // Must live under ABSPATH and be literally ".htaccess"
     775
     776        if ( strpos( $real, $base ) !== 0 || basename( $real ) !== '.htaccess' || ! is_file( $real ) ) {
     777
     778            return false;
     779
     780        }
     781
     782        return $real;
     783
     784    }
     785
     786    /**
     787
     788     * Download a single .htaccess file as a tiny ZIP (named "htaccess.zip").
     789
     790     *
     791
     792     * Nonce: 'fa_htaccess_download_' . md5($rel)
     793
     794     * POST:  rel
     795
     796     */
     797
     798    public function handle_htaccess_download() {
     799
     800        if ( ! $this->can_manage() ) {
     801
     802            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     803
     804        }
     805
     806        $rel = (string) ( filter_input( INPUT_POST, 'rel', FILTER_UNSAFE_RAW ) ?? '' );
     807
     808    $rel = sanitize_text_field( $rel );
     809
     810    check_admin_referer( 'fa_htaccess_download_' . md5( $rel ) );
     811
     812        $abs = $this->get_safe_htaccess_file( $rel );
     813
     814        if ( ! $abs ) {
     815
     816            wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
     817
     818        }
     819
     820        if ( ! class_exists( 'ZipArchive' ) ) {
     821
     822            wp_die( esc_html__( 'ZipArchive is not available on this server.', 'folder-auditor' ) );
     823
     824        }
     825
     826        // Build one-file ZIP (keeps filename exactly ".htaccess" inside)
     827
     828        $zip_file = wp_tempnam( 'fa-htaccess-' ) . '.zip';
     829
     830        $zip = new ZipArchive();
     831
     832        if ( true !== $zip->open( $zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
     833
     834            // Safe cleanup via WP helper
     835
     836            wp_delete_file( $zip_file );
     837
     838            wp_die( esc_html__( 'Failed to build ZIP.', 'folder-auditor' ) );
     839
     840        }
     841
     842        $zip->addFile( $abs, '.htaccess' );
     843
     844        $zip->close();
     845
     846        if ( function_exists( 'ob_get_level' ) ) {
     847
     848            while ( ob_get_level() ) { ob_end_clean(); }
     849
     850        }
     851
     852        // Use Filesystem API to read + echo; avoid readfile()
     853
     854        $wp_filesystem = $this->fa_require_filesystem();
     855
     856        $size          = is_file( $zip_file ) ? (int) filesize( $zip_file ) : 0;
     857
     858        $contents      = $wp_filesystem->get_contents( $zip_file );
     859
     860        nocache_headers();
     861
     862        header( 'Content-Type: application/zip' );
     863
     864        if ( $size > 0 ) {
     865
     866            header( 'Content-Length: ' . $size );
     867
     868        }
     869
     870        header( 'Content-Disposition: attachment; filename="htaccess.zip"; filename*=UTF-8\'\'htaccess.zip' );
     871
     872        if ( false !== $contents ) {
     873
     874            echo $contents; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
     875
     876        }
     877
     878        // Remove temp file via WP helper (no unlink()).
     879
     880        wp_delete_file( $zip_file );
    933881
    934882        exit;
    935883
    936 
    937884    }
    938885
    939 
    940     $root_norm     = wp_normalize_path( $abs_root );
    941 
    942 
    943     $wp_filesystem = $this->fa_require_filesystem();
    944 
    945 
    946     $scan          = function() use ( $abs_root ) { return $this->scan_htaccess_for_bulk( $abs_root ); };
    947 
    948 
    949     $total_deleted = 0;
    950 
    951 
    952     $total_failed  = 0;
    953 
    954 
    955     // ---- progress-driven loop with time budget ----
    956 
    957 
    958     $deadline_sec  = 30;                               // adjust if you want
    959 
    960 
    961     $deadline_at   = microtime( true ) + $deadline_sec;
    962 
    963 
    964     $stable_scans  = 0;                                // need 2 consecutive stable scans to stop
    965 
    966 
    967     $last_left     = null;
    968 
    969 
    970     while ( microtime( true ) < $deadline_at ) {
    971 
    972 
    973         $files = $scan();
    974 
    975 
    976         $left  = count( $files );
    977 
    978 
    979         // Already stable?
    980 
    981 
    982         if ( $left === 0 ) { break; }
    983 
    984 
    985         if ( $last_left !== null && $left === $last_left ) {
    986 
    987 
    988             $stable_scans++;
    989 
    990 
    991             if ( $stable_scans >= 2 ) { break; }       // nothing is changing anymore
    992 
     886    /**
     887
     888     * Delete a single .htaccess file.
     889
     890     *
     891
     892     * Nonce: 'fa_htaccess_delete_' . md5($rel)
     893
     894     * POST:  rel
     895
     896     */
     897
     898    public function handle_htaccess_delete() {
     899
     900        if ( ! $this->can_manage() ) {
     901
     902            wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
     903
     904        }
     905
     906        $rel = (string) ( filter_input( INPUT_POST, 'rel', FILTER_UNSAFE_RAW ) ?? '' );
     907
     908    $rel = sanitize_text_field( $rel );
     909
     910    check_admin_referer( 'fa_htaccess_delete_' . md5( $rel ) );
     911
     912        $abs = $this->get_safe_htaccess_file( $rel );
     913
     914        if ( ! $abs ) {
     915
     916            wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
     917
     918        }
     919
     920        $wp_filesystem = $this->fa_require_filesystem();
     921
     922        // Prefer FS API; fallback to wp_delete_file
     923
     924        $ok = true;
     925
     926        if ( method_exists( $wp_filesystem, 'delete' ) ) {
     927
     928            $ok = $wp_filesystem->delete( $abs, false, 'f' );
    993929
    994930        } else {
    995931
    996 
    997             $stable_scans = 0;
    998 
    999 
    1000             $last_left    = $left;
    1001 
    1002 
    1003         }
    1004 
    1005 
    1006         // Depth-first: longer paths first
    1007 
    1008 
    1009         usort( $files, static function( $a, $b ){ return strlen( $b ) <=> strlen( $a ); });
    1010 
    1011 
    1012         $deleted_this_pass = 0;
    1013 
    1014 
    1015         foreach ( $files as $abs ) {
    1016 
    1017 
    1018             $real = realpath( $abs );
    1019 
    1020 
    1021             if ( ! $real ) { $total_failed++; continue; }
    1022 
    1023 
    1024             $real_norm = wp_normalize_path( $real );
    1025 
    1026 
    1027             if ( strpos( $real_norm, $root_norm ) !== 0 || basename( $real_norm ) !== '.htaccess' || ! is_file( $real_norm ) ) {
    1028 
    1029 
    1030                 $total_failed++; continue;
    1031 
    1032 
    1033             }
    1034 
    1035 
    1036             $ok = $this->try_delete_htaccess( $real_norm, $wp_filesystem );
    1037 
    1038 
    1039             if ( $ok ) {
    1040 
    1041 
    1042                 $deleted_this_pass++; $total_deleted++;
    1043 
    1044 
    1045                 if ( method_exists( $this, 'ignore_remove' ) ) {
    1046 
    1047 
    1048                     $rel = ltrim( str_replace( $root_norm, '', $real_norm ), '/\\' );
    1049 
    1050 
    1051                     $this->ignore_remove( 'htaccess', $rel );
    1052 
    1053 
    1054                 }
    1055 
    1056 
    1057             } else {
    1058 
    1059 
    1060                 $total_failed++;
    1061 
    1062 
    1063             }
    1064 
    1065 
    1066         }
    1067 
    1068 
    1069         // Make sure PHP/FS caches are flushed and give the FS a breath
    1070 
    1071 
    1072         if ( function_exists( 'clearstatcache' ) ) { clearstatcache( true ); }
    1073 
    1074 
    1075         usleep( 150000 ); // 150ms; helps on NFS/slow FS
    1076 
    1077 
    1078         // If nothing deleted in this pass, mark stability (second check happens at loop head)
    1079 
    1080 
    1081         if ( $deleted_this_pass === 0 ) { /* no-op - stability logic above will catch this */ }
    1082 
     932            $ok = (bool) wp_delete_file( $abs );
     933
     934        }
     935
     936        if ( ! $ok ) {
     937
     938            wp_die( esc_html__( 'Failed to delete the file (permissions?).', 'folder-auditor' ) );
     939
     940        }
     941
     942        // Back to the tab with a success context
     943
     944        $redirect = add_query_arg(
     945
     946            array(
     947
     948                'page'            => self::MENU_SLUG,
     949
     950                'tab'             => 'htaccess',
     951
     952                'fa_htaccess_msg' => rawurlencode( $rel ),
     953
     954            ),
     955
     956            admin_url( 'admin.php' )
     957
     958        );
     959
     960        wp_safe_redirect( $redirect );
     961
     962        exit;
    1083963
    1084964    }
    1085965
    1086 
    1087     $remaining = count( $scan() );
    1088 
    1089 
    1090     $args = array(
    1091 
    1092 
    1093         'page'             => self::MENU_SLUG,
    1094 
    1095 
    1096         'tab'              => 'htaccess',
    1097 
    1098 
    1099         'fa_htaccess_bulk' => (int) $total_deleted,
    1100 
    1101 
    1102         'fa_htaccess_fail' => (int) $total_failed,
    1103 
    1104 
    1105         'fa_htaccess_left' => (int) $remaining,
    1106 
    1107 
    1108     );
    1109 
    1110 
    1111     wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
    1112 
    1113 
    1114     exit;
    1115 
    1116 
    1117966}
    1118 
    1119 
    1120     /**
    1121 
    1122 
    1123      * Utility: scan for .htaccess files under a root (absolute paths).
    1124 
    1125 
    1126      *
    1127 
    1128 
    1129      * @param string $root
    1130 
    1131 
    1132      * @return string[] absolute paths
    1133 
    1134 
    1135      */
    1136 
    1137 
    1138 private function scan_htaccess_for_bulk( $root ) {
    1139 
    1140 
    1141     $found = [];
    1142 
    1143 
    1144     if ( ! $root || ! is_dir( $root ) ) { return $found; }
    1145 
    1146 
    1147     $root_real = realpath( $root );
    1148 
    1149 
    1150     if ( ! $root_real ) { return $found; }
    1151 
    1152 
    1153     $root_norm = wp_normalize_path( $root_real );
    1154 
    1155 
    1156     // Keep this list in sync with other scanners
    1157 
    1158 
    1159     $skip = array( '.git', '.svn', 'node_modules', 'vendor', 'cache', 'backups', 'backup' );
    1160 
    1161 
    1162     try {
    1163 
    1164 
    1165         $dirFlags = FilesystemIterator::SKIP_DOTS;
    1166 
    1167 
    1168         // Prevent UnexpectedValueException from aborting traversal on unreadable dirs
    1169 
    1170 
    1171         if ( defined('RecursiveDirectoryIterator::CATCH_GET_CHILD') ) {
    1172 
    1173 
    1174             $dirFlags |= RecursiveDirectoryIterator::CATCH_GET_CHILD;
    1175 
    1176 
    1177         }
    1178 
    1179 
    1180         $it = new RecursiveIteratorIterator(
    1181 
    1182 
    1183             new RecursiveDirectoryIterator( $root_real, $dirFlags ),
    1184 
    1185 
    1186             RecursiveIteratorIterator::SELF_FIRST
    1187 
    1188 
    1189         );
    1190 
    1191 
    1192         foreach ( $it as $fi ) {
    1193 
    1194 
    1195             // Skip heavy/vendor dirs early
    1196 
    1197 
    1198             if ( $fi->isDir() ) {
    1199 
    1200 
    1201                 if ( in_array( $fi->getBasename(), $skip, true ) ) {
    1202 
    1203 
    1204                     $it->next();
    1205 
    1206 
    1207                 }
    1208 
    1209 
    1210                 continue;
    1211 
    1212 
    1213             }
    1214 
    1215 
    1216             if ( ! $fi->isFile() ) { continue; }
    1217 
    1218 
    1219             if ( $fi->getBasename() !== '.htaccess' ) { continue; }
    1220 
    1221 
    1222             // Normalize & safety-check (avoid symlink escapes)
    1223 
    1224 
    1225             $real = realpath( $fi->getPathname() );
    1226 
    1227 
    1228             if ( ! $real ) { continue; }
    1229 
    1230 
    1231             $real_norm = wp_normalize_path( $real );
    1232 
    1233 
    1234             if ( strpos( $real_norm, $root_norm ) !== 0 ) { continue; }
    1235 
    1236 
    1237             $found[] = $real_norm;
    1238 
    1239 
    1240         }
    1241 
    1242 
    1243     } catch ( \Exception $e ) {
    1244 
    1245 
    1246         // Swallow to keep bulk op resilient
    1247 
    1248 
    1249     }
    1250 
    1251 
    1252     return $found;
    1253 
    1254 
    1255 }
    1256 
    1257 
    1258     /**
    1259 
    1260 
    1261      * Resolve a safe absolute path for a relative path under ABSPATH,
    1262 
    1263 
    1264      * restricted strictly to files literally named ".htaccess".
    1265 
    1266 
    1267      *
    1268 
    1269 
    1270      * @param string $rel Relative path (e.g., 'wp-content/uploads/.htaccess')
    1271 
    1272 
    1273      * @return string|false Absolute path if valid and safe, false otherwise
    1274 
    1275 
    1276      */
    1277 
    1278 
    1279     private function get_safe_htaccess_file( string $rel ) {
    1280 
    1281 
    1282         $rel = wp_normalize_path( sanitize_text_field( $rel ) );
    1283 
    1284 
    1285         // Deny traversal / absolute paths
    1286 
    1287 
    1288         if ( $rel === '' || strpos( $rel, '..' ) !== false || preg_match( '#^[\\/]|[a-zA-Z]:[\\\\/]#', $rel ) ) {
    1289 
    1290 
    1291             return false;
    1292 
    1293 
    1294         }
    1295 
    1296 
    1297         $base = wp_normalize_path( realpath( ABSPATH ) );
    1298 
    1299 
    1300         if ( ! $base ) { return false; }
    1301 
    1302 
    1303         $abs  = wp_normalize_path( $base . '/' . ltrim( $rel, '/\\' ) );
    1304 
    1305 
    1306         $real = realpath( $abs );
    1307 
    1308 
    1309         if ( ! $real ) { return false; }
    1310 
    1311 
    1312         $real = wp_normalize_path( $real );
    1313 
    1314 
    1315         // Must live under ABSPATH and be literally ".htaccess"
    1316 
    1317 
    1318         if ( strpos( $real, $base ) !== 0 || basename( $real ) !== '.htaccess' || ! is_file( $real ) ) {
    1319 
    1320 
    1321             return false;
    1322 
    1323 
    1324         }
    1325 
    1326 
    1327         return $real;
    1328 
    1329 
    1330     }
    1331 
    1332 
    1333     /**
    1334 
    1335 
    1336      * Download a single .htaccess file as a tiny ZIP (named "htaccess.zip").
    1337 
    1338 
    1339      *
    1340 
    1341 
    1342      * Nonce: 'fa_htaccess_download_' . md5($rel)
    1343 
    1344 
    1345      * POST:  rel
    1346 
    1347 
    1348      */
    1349 
    1350 
    1351     public function handle_htaccess_download() {
    1352 
    1353 
    1354         if ( ! $this->can_manage() ) {
    1355 
    1356 
    1357             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    1358 
    1359 
    1360         }
    1361 
    1362 
    1363         $rel = (string) ( filter_input( INPUT_POST, 'rel', FILTER_UNSAFE_RAW ) ?? '' );
    1364 
    1365 
    1366     $rel = sanitize_text_field( $rel );
    1367 
    1368 
    1369     check_admin_referer( 'fa_htaccess_download_' . md5( $rel ) );
    1370 
    1371 
    1372         $abs = $this->get_safe_htaccess_file( $rel );
    1373 
    1374 
    1375         if ( ! $abs ) {
    1376 
    1377 
    1378             wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
    1379 
    1380 
    1381         }
    1382 
    1383 
    1384         if ( ! class_exists( 'ZipArchive' ) ) {
    1385 
    1386 
    1387             wp_die( esc_html__( 'ZipArchive is not available on this server.', 'folder-auditor' ) );
    1388 
    1389 
    1390         }
    1391 
    1392 
    1393         // Build one-file ZIP (keeps filename exactly ".htaccess" inside)
    1394 
    1395 
    1396         $zip_file = wp_tempnam( 'fa-htaccess-' ) . '.zip';
    1397 
    1398 
    1399         $zip = new ZipArchive();
    1400 
    1401 
    1402         if ( true !== $zip->open( $zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
    1403 
    1404 
    1405             // Safe cleanup via WP helper
    1406 
    1407 
    1408             wp_delete_file( $zip_file );
    1409 
    1410 
    1411             wp_die( esc_html__( 'Failed to build ZIP.', 'folder-auditor' ) );
    1412 
    1413 
    1414         }
    1415 
    1416 
    1417         $zip->addFile( $abs, '.htaccess' );
    1418 
    1419 
    1420         $zip->close();
    1421 
    1422 
    1423         if ( function_exists( 'ob_get_level' ) ) {
    1424 
    1425 
    1426             while ( ob_get_level() ) { ob_end_clean(); }
    1427 
    1428 
    1429         }
    1430 
    1431 
    1432         // Use Filesystem API to read + echo; avoid readfile()
    1433 
    1434 
    1435         $wp_filesystem = $this->fa_require_filesystem();
    1436 
    1437 
    1438         $size          = is_file( $zip_file ) ? (int) filesize( $zip_file ) : 0;
    1439 
    1440 
    1441         $contents      = $wp_filesystem->get_contents( $zip_file );
    1442 
    1443 
    1444         nocache_headers();
    1445 
    1446 
    1447         header( 'Content-Type: application/zip' );
    1448 
    1449 
    1450         if ( $size > 0 ) {
    1451 
    1452 
    1453             header( 'Content-Length: ' . $size );
    1454 
    1455 
    1456         }
    1457 
    1458 
    1459         header( 'Content-Disposition: attachment; filename="htaccess.zip"; filename*=UTF-8\'\'htaccess.zip' );
    1460 
    1461 
    1462         if ( false !== $contents ) {
    1463 
    1464 
    1465             echo $contents; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sending raw file bytes
    1466 
    1467 
    1468         }
    1469 
    1470 
    1471         // Remove temp file via WP helper (no unlink()).
    1472 
    1473 
    1474         wp_delete_file( $zip_file );
    1475 
    1476 
    1477         exit;
    1478 
    1479 
    1480     }
    1481 
    1482 
    1483     /**
    1484 
    1485 
    1486      * Delete a single .htaccess file.
    1487 
    1488 
    1489      *
    1490 
    1491 
    1492      * Nonce: 'fa_htaccess_delete_' . md5($rel)
    1493 
    1494 
    1495      * POST:  rel
    1496 
    1497 
    1498      */
    1499 
    1500 
    1501     public function handle_htaccess_delete() {
    1502 
    1503 
    1504         if ( ! $this->can_manage() ) {
    1505 
    1506 
    1507             wp_die( esc_html__( 'You do not have permission to do this.', 'folder-auditor' ) );
    1508 
    1509 
    1510         }
    1511 
    1512 
    1513         $rel = (string) ( filter_input( INPUT_POST, 'rel', FILTER_UNSAFE_RAW ) ?? '' );
    1514 
    1515 
    1516     $rel = sanitize_text_field( $rel );
    1517 
    1518 
    1519     check_admin_referer( 'fa_htaccess_delete_' . md5( $rel ) );
    1520 
    1521 
    1522         $abs = $this->get_safe_htaccess_file( $rel );
    1523 
    1524 
    1525         if ( ! $abs ) {
    1526 
    1527 
    1528             wp_die( esc_html__( 'Invalid file.', 'folder-auditor' ) );
    1529 
    1530 
    1531         }
    1532 
    1533 
    1534         $wp_filesystem = $this->fa_require_filesystem();
    1535 
    1536 
    1537         // Prefer FS API; fallback to wp_delete_file
    1538 
    1539 
    1540         $ok = true;
    1541 
    1542 
    1543         if ( method_exists( $wp_filesystem, 'delete' ) ) {
    1544 
    1545 
    1546             $ok = $wp_filesystem->delete( $abs, false, 'f' );
    1547 
    1548 
    1549         } else {
    1550 
    1551 
    1552             $ok = (bool) wp_delete_file( $abs );
    1553 
    1554 
    1555         }
    1556 
    1557 
    1558         if ( ! $ok ) {
    1559 
    1560 
    1561             wp_die( esc_html__( 'Failed to delete the file (permissions?).', 'folder-auditor' ) );
    1562 
    1563 
    1564         }
    1565 
    1566 
    1567         // Back to the tab with a success context
    1568 
    1569 
    1570         $redirect = add_query_arg(
    1571 
    1572 
    1573             array(
    1574 
    1575 
    1576                 'page'            => self::MENU_SLUG,
    1577 
    1578 
    1579                 'tab'             => 'htaccess',
    1580 
    1581 
    1582                 'fa_htaccess_msg' => rawurlencode( $rel ),
    1583 
    1584 
    1585             ),
    1586 
    1587 
    1588             admin_url( 'admin.php' )
    1589 
    1590 
    1591         );
    1592 
    1593 
    1594         wp_safe_redirect( $redirect );
    1595 
    1596 
    1597         exit;
    1598 
    1599 
    1600     }
    1601 
    1602 
    1603 }
  • folder-auditor/trunk/includes/helpers/scanner/scanner.php

    r3380575 r3381342  
    854854        return [ $raw, $norm ];
    855855    }
    856 protected function wpfa_get_raw_patterns() : array {
    857     return [
    858         'socketapiupdates\\.com',
    859         'adolescent_deployment',
    860         'decent[-_ ]follower',
    861         'Farrell\\s*-\\s*Herzog',
    862         'decent_follower\\.php|class\\/pick\\.php',
    863         'sneaky_arrange_calculating\\.php',
    864         'fatally_unsightly_quirkily\\s*\\(|verbally_concrete\\s*\\(|indelible_ethyl\\s*\\(|eyebrow_gracefully_whitewash\\s*\\(|harp_passionate\\s*\\(',
    865         '^\x7fELF',
    866         '^MZ',
    867         '^\xCF\xFA\xED',
    868         '\b(?:require|require_once|include|include_once)\s*\(?\s*(?:@?\s*)?(?:base64_decode|str_rot13|gzinflate|gzuncompress)\s*\(',
    869         'error_reporting\\s*\\(\\s*0\\s*\\)\\s*;\\s*\\$LlCam\\s*=\\s*array\\(\\s*\"\\\\x5f\\\\107\\\\x45\\\\x54\"\\s*\\)\\s*;',
    870         '\\$\\{\\s*\\$LlCam\\s*\\[\\s*0\\s*\\]\\s*\\}',
    871         '@?require_once\\s*[\'"](?:\\\\x7a\\\\x69\\\\x70)[^\'"]*[\'"]',
    872         '\\\\x65\\\\x64\\\\x31\\\\x31\\\\x30\\\\x62\\\\x65\\\\x62\\\\x63\\\\x65\\\\x39\\\\x2e\\\\x74\\\\x6d\\\\x70',
    873         'ycycsUnT3uBLqyUrzfdIDg23r',
    874         '\beval\s*\(\s*base64_decode\s*\(',
    875         '\bpreg_replace\s*\(\s*([\'"])\s*([^\w\s\\])(?:\\.|(?!\2).)*\2(?=[A-Za-z]*e[A-Za-z]*\1)',
    876         '\b(?:include|require|include_once|require_once)\s*(?:\(\s*)?[\'"]https?:\/\/',
    877         '\b(?:exec|shell_exec|system|passthru|proc_open|popen|pcntl_exec)\s*\([^)]*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b',
    878         'shell_exec\s*\([^)]*\$_REQUEST',
    879         '\b(?:file_put_contents|fopen|fwrite|fputs)\s*\(\s*\$[A-Za-z_]\w*\s*,\s*base64_decode\s*\(',
    880         '[\'"]\s*e\s*[\'"]\s*\.\s*[\'"]\s*val\s*[\'"]',
    881         '[\'"]\s*base\s*[\'"]\s*\.\s*[\'"]\s*64\s*[\'"]\s*\.\s*[\'"]\s*decode\s*[\'"]',
    882         '\$\w+\s*=\s*[\'"][a-z]{1,3}[\'"]\s*;\s*\$\w+\s*=\s*[\'"][a-z]{1,3}[\'"]\s*;\s*\$\w+\s*=\s*\$\w+\s*\.\s*\$\w+\s*\.\s*[\'"](tem|val|sert)[\'"]\s*;',
    883         'ob_implicit_flush\s*\(\s*true\s*\)\s*;[\s\S]{0,160}\bob_end_flush\s*\(',
    884         '(?s)(?:OPENSSL_RAW_DATA.*substr\s*\(\s*hash\s*\(\s*[\'"]sha256[\'"]|substr\s*\(\s*hash\s*\(\s*[\'"]sha256[\'"].*OPENSSL_RAW_DATA|[\'"]<\s*\/?\s*scr?\s*[\'"]\s*\.\s*[\'"]r?ipt\s*>[\'"])',
    885         '(?s)\$[A-Za-z_]\w*\s*=\s*\$_(?:POST|REQUEST)\s*;.*?isset\s*\(\s*\$[A-Za-z_]\w*\s*\[[\'"][a-z0-9_]{3,}[\'"]\]\s*\).*?\$\w+\s*\(\s*\.\.\.\$\w+\s*\)',
    886         '(?s)readfile\s*\(\s*base64_decode\s*\(\s*["\'][^"\']{8,}["\']\s*\)\s*\)\s*;.*?eval\s*\(\s*.*?ob_get_clean\s*\(\s*\)\s*\)\s*;',
    887         'function\s*uPqmvR\s*\(',
    888         'function\s*yh1\s*\(',
    889         '\bUpVwwHRQ\s*\(',
    890         'array_map\(\s*[\'"]md5[\'"]\s*,\s*\$_COOKIE',
    891         '\$gi6\[\d+\]\s*\(\s*\$_(?:COOKIE|POST|REQUEST)',
    892         'include\s*\(\s*base64_decode\s*\(\s*\$[A-Za-z_]\w*\s*\)\s*(?:\.\s*)?\)\s*;',
    893         'call_user_func\s*\(\s*new\s+LiteSpeedMetaDataStore'
    894     ];
     856   
     857    protected function wpfa_get_raw_patterns(): array {
     858        $patterns = include __DIR__ . '/patterns.php';
     859        return $patterns['raw'];
     860    }
     861   
     862    protected function wpfa_get_patterns(): array {
     863        $patterns = include __DIR__ . '/patterns.php';
     864        return $patterns['scan'];
     865    }
    895866}
    896 
    897 protected function wpfa_get_patterns() : array {
    898     return [
    899         'socketapiupdates\\.com',
    900         'adolescent_deployment',
    901         'decent[-_ ]follower',
    902         'Farrell\\s*-\\s*Herzog',
    903         'decent_follower\\.php|class\\/pick\\.php',
    904         'sneaky_arrange_calculating\\.php',
    905         'fatally_unsightly_quirkily\\s*\\(|verbally_concrete\\s*\\(|indelible_ethyl\\s*\\(|eyebrow_gracefully_whitewash\\s*\\(|harp_passionate\\s*\\(',
    906         '^\x7fELF',
    907         '^MZ',
    908         '^\xCF\xFA\xED',
    909         '\b(?:require|require_once|include|include_once)\s*\(?\s*(?:@?\s*)?(?:base64_decode|str_rot13|gzinflate|gzuncompress)\s*\(',
    910         'error_reporting\\s*\\(\\s*0\\s*\\)\\s*;\\s*\\$LlCam\\s*=\\s*array\\s*\\(',
    911         '\\$\\{\\s*\\$LlCam\\s*\\[\\s*0\\s*\\]\\s*\\}',
    912         '@?require_once\\s*[\\\'\\"][^\\\'\\\"]{3,256}[\\\'\\"]',
    913         'ed110bebce9\\.tmp',
    914         'ycycsUnT3uBLqyUrzfdIDg23r',
    915         '\b(?:fopen|fwrite|fputs|file_put_contents|file_get_contents|fclose|chmod|unlink)\s*\([^)]*base64_decode\s*\(',
    916         'basename\s*\(\s*__FILE__\s*,\s*base64_decode\s*\(',
    917         '\b(?:exec|shell_exec|system|passthru|proc_open|popen|pcntl_exec)\s*\([^)]*base64_decode\s*\(',
    918         '\b(?:exec|shell_exec|system|passthru|proc_open|popen|pcntl_exec)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER)\b',
    919         '>\s*\/dev\/null\s*2>\s*\/dev\/null\s*&',
    920         '(?:base64_encode\s*\(\s*){2,}[^)]*\)',
    921         '(?:base64_decode\s*\(\s*){2,}[^)]*\)',
    922         'openssl_(?:en|de)crypt\s*\([^,]+,\s*[\'"]?\s*aes\s*[-_ ]?(?:128|192|256)\s*[-_ ]?cbc[\'"]?\s*,[^)]*hash\s*\(\s*[\'"]sha256[\'"]\s*,\s*[\'"][^\'"]{8,}[\'"]\s*,\s*true\s*\)[^)]*\)',
    923         'substr\s*\(\s*hash\s*\(\s*[\'"]sha256[\'"]\s*,\s*[\'"][^\'"]{8,}[\'"]\s*,\s*true\s*\)\s*,\s*0\s*,\s*16\s*\)',
    924         '\b(?:include|require|include_once|require_once)\s*\(\s*base64_decode\s*\(',
    925         '\b(?:include|require|include_once|require_once|file_get_contents|fopen)\s*\([^)]*(?:php:\/\/input|php:\/\/filter|data:\/\/)',
    926         'md5\s*\(\s*uniqid\s*\([^)]*\)\s*\)\s*\.\s*[\'"]\.(?:php|phtml)\b',
    927         'curl_init\s*\([^)]*\)\s*;[^;]*CURLOPT_(?:HTTPHEADER|COOKIE)[^;]*(?:Authorization|Cookie)[^;]*token\s*=\s*',
    928         'wp_remote_request\s*\([^)]*\bheaders\b[^)]*(?:Authorization|Cookie)[\'"]\s*=>\s*[\'"][^\'"]*token=',
    929         '`[^`]{1,200}`',
    930         '[\'"]<\s*sc[\'"]\s*\.\s*[\'"]ript\s*>[\'"]',
    931         '[\'"]<\/\s*scr[\'"]\s*\.\s*[\'"]ipt\s*>[\'"]',
    932         'array\s*\(\s*(?:\s*[\'"][a-z0-9][\'"]\s*,){3,}\s*[\'"][a-z0-9][\'"]\s*\)',
    933         '\$[A-Za-z_]\w*\s*=\s*\$\w+\s*\.\s*\$\w+\s*\.\s*[\'"](tem|xec)[\'"]',
    934         '(?s)readfile\s*\(\s*base64_decode\s*\(.*?\)\s*\).*?eval\s*\(\s*.*?ob_get_clean\s*\(\s*\).*?\)',
    935         '\$[A-Za-z_]\w*\s*=\s*\$[A-Za-z_]\w*\s*\[[\'"][a-z0-9_]{3,}[\'"]\]\s*;.*\$\w+\s*\(\s*\.\.\.\$\w+\s*\)',
    936         'function\s*uPqmvR\s*\(',
    937         'function\s*yh1\s*\(',
    938         '\bUpVwwHRQ\s*\(',
    939         'array_map\(\s*[\'"]md5[\'"]\s*,\s*\$_COOKIE',
    940         '\$gi6\[\d+\]\s*\(\s*\$_(?:COOKIE|POST|REQUEST)',
    941         'include\s*\(\s*base64_decode\s*\(\s*\$[A-Za-z_]\w*\s*\)\s*(?:\.\s*)?\)\s*;',
    942         'call_user_func\s*\(\s*new\s+LiteSpeedMetaDataStore'
    943     ];
    944 }   
    945 }
  • folder-auditor/trunk/includes/summaries/summary-htaccess.php

    r3368707 r3381342  
    33trait WPFA_htaccess_summary_functions {
    44
     5 
     6    /**
     7
     8     * Build a summary for the .htaccess metric card (raw vs effective, honoring "ignored").
     9
     10     *
     11
     12     * @return array {
     13
     14     *   @type string key
     15
     16     *   @type string label
     17
     18     *   @type string tab
     19
     20     *   @type int    count            Raw total
     21
     22     *   @type int    count_effective  Total minus ignored
     23
     24     * }
     25
     26     */
     27
     28private function summary_htaccess() : array {
     29
     30    $base    = realpath( ABSPATH );
     31    $all_abs = [];
     32    $all_rel = [];
     33
     34    if ( $base && is_dir( $base ) && is_readable( $base ) ) {
     35
     36        try {
     37            $it = new RecursiveIteratorIterator(
     38                new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ),
     39                RecursiveIteratorIterator::SELF_FIRST
     40            );
     41
     42            $skip = [ '.git', '.svn', 'node_modules', 'vendor', 'cache', 'backups', 'backup' ];
     43
     44            foreach ( $it as $fi ) {
     45                if ( $fi->isDir() ) {
     46                    if ( in_array( $fi->getBasename(), $skip, true ) ) { $it->next(); }
     47                    continue;
     48                }
     49
     50                if ( ! $fi->isFile() ) { continue; }
     51                if ( $fi->getBasename() !== '.htaccess' ) { continue; }
     52
     53                $abs = realpath( $fi->getPathname() );
     54                if ( ! $abs ) { continue; }
     55                if ( strpos( $abs, $base ) !== 0 ) { continue; }
     56
     57                // ✅ Exclude specific file: wp-content/plugins/mainwp-child/.htaccess
     58                $rel = str_replace('\\', '/', ltrim( str_replace( $base, '', $abs ), '/\\' ));
     59                if ( strtolower( $rel ) === 'wp-content/plugins/mainwp-child/.htaccess' ) {
     60                    continue;
     61                }
     62
     63                $all_abs[] = $abs;
     64                $all_rel[] = $rel; // same rel format as view
     65            }
     66
     67        } catch ( \Exception $e ) { /* ignore */ }
     68    }
     69
     70    $raw_count = count( $all_rel );
     71
     72    // Apply ignore set (bucket: 'htaccess'; keys are REL paths like "wp-content/uploads/.htaccess")
     73    $ignored          = method_exists( $this, 'get_ignored' ) ? (array) $this->get_ignored() : [];
     74    $ignored_keys     = array_keys( (array) ( $ignored['htaccess'] ?? [] ) );
     75    $ignored_in_set   = count( array_intersect( $all_rel, $ignored_keys ) );
     76    $count_effective  = max( 0, $raw_count - $ignored_in_set );
     77
     78    return [
     79        'key'             => 'htaccess',
     80        'label'           => __( '.htaccess Files', 'folder-auditor' ),
     81        'tab'             => 'htaccess',
     82        'count'           => (int) $raw_count,         // raw total for display
     83        'count_effective' => (int) $count_effective,   // excludes ignored (for health score)
     84    ];
     85}
    586
    687}
  • folder-auditor/trunk/includes/summaries/summary-plugins.php

    r3367997 r3381342  
    1515    // Skip specific plugin folders entirely (e.g., "support-wpfi")
    1616
    17     $skip_slugs = apply_filters( 'folder_auditor_plugin_skip_slugs', array( 'support-wpfi' ) );
     17    $skip_slugs = apply_filters( 'folder_auditor_plugin_skip_slugs', array( 'support-wpfi', 'mainwp-child', 'mainwp-child-reports' ) );
    1818
    1919    $skip_lc    = array();
  • folder-auditor/trunk/includes/summaries/summary-uploads.php

    r3367997 r3381342  
    4949    }
    5050
    51     // ── scan uploads recursively for any *.php (for health score / card)
     51// ── scan uploads recursively for any *.php (for health score / card)
     52if ( is_dir( $base ) && is_readable( $base ) ) {
     53    try {
     54        $rii = new RecursiveIteratorIterator(
     55            new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS )
     56        );
    5257
    53     if ( is_dir( $base ) && is_readable( $base ) ) {
     58        foreach ( $rii as $fi ) {
     59            if ( $fi->isFile() && preg_match( '/\.php$/i', $fi->getFilename() ) ) {
    5460
    55         try {
     61                // relative path like "2024/08/shell.php" or "file.php"
     62                $full = $fi->getPathname();
     63                $rel  = ltrim( str_replace('\\','/', substr( $full, strlen( $base ) ) ), '/' );
    5664
    57             $rii = new RecursiveIteratorIterator(
    58 
    59                 new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS )
    60 
    61             );
    62 
    63             foreach ( $rii as $fi ) {
    64 
    65                 if ( $fi->isFile() && preg_match( '/\.php$/i', $fi->getFilename() ) ) {
    66 
    67                     // relative path like "2024/08/shell.php" or "file.php"
    68 
    69                     $full = $fi->getPathname();
    70 
    71                     $rel  = ltrim( str_replace('\\','/', substr( $full, strlen( $base ) ) ), '/' );
    72 
    73                     $php_any_rel[] = $rel;
    74 
     65                // ✅ Exclude "mainwp/index.php"
     66                if ( strtolower( $rel ) === 'mainwp/index.php' ) {
     67                    continue;
    7568                }
    7669
     70                $php_any_rel[] = $rel;
    7771            }
     72        }
    7873
    79         } catch ( \Exception $e ) { /* ignore */ }
    80 
    81     }
     74    } catch ( \Exception $e ) { /* ignore */ }
     75}
    8276
    8377    // ── apply “Ignore” (if user has marked items to exclude)
  • folder-auditor/trunk/includes/views/view-htaccess-files.php

    r3374418 r3381342  
    101101              if ( ! $fi->isFile() ) { continue; }
    102102
    103               if ( $fi->getBasename() !== '.htaccess' ) { continue; }
    104 
    105               $real = realpath( $fi->getPathname() );
    106 
    107               if ( ! $real ) { continue; }
     103        if ( $fi->getBasename() !== '.htaccess' ) { continue; }
     104       
     105        $real = realpath( $fi->getPathname() );
     106       
     107        if ( ! $real ) { continue; }
     108       
     109        // ✅ Exclude the specific file wp-content/plugins/mainwp-child/.htaccess
     110        $rel = str_replace('\\', '/', ltrim( str_replace( realpath( ABSPATH ), '', $real ), '/' ));
     111        if ( strtolower( $rel ) === 'wp-content/plugins/mainwp-child/.htaccess' ) {
     112            continue;
     113        }
    108114
    109115              if ( strpos( $real, $base ) !== 0 ) { continue; }
  • folder-auditor/trunk/includes/views/view-plugins.php

    r3374418 r3381342  
    55<?php
    66
    7 // Ignore 'support-wpfi' everywhere (counts + tables)
    8 
    9 $fa_ignore_slug = 'support-wpfi';
    10 
    11 $fa_eq = static function($a,$b){ return strtolower((string)$a) === strtolower((string)$b); };
     7// Ignore these slugs everywhere (counts + tables)
     8$fa_ignore_slugs = array_map('strtolower', [
     9    'support-wpfi',
     10    'mainwp-child',
     11    'mainwp-child-reports',
     12]);
     13
     14$fa_is_ignored = static function( $s ) use ( $fa_ignore_slugs ) {
     15    $s = strtolower( (string) $s );
     16    return in_array( $s, $fa_ignore_slugs, true );
     17};
    1218
    1319// Remove from the disk map used for lookups/existence checks
    14 
    15 if (isset($folders_map) && is_array($folders_map)) {
    16 
    17     foreach (array_keys($folders_map) as $k) {
    18 
    19         if ($fa_eq($k, $fa_ignore_slug)) { unset($folders_map[$k]); }
    20 
     20if ( isset( $folders_map ) && is_array( $folders_map ) ) {
     21    foreach ( array_keys( $folders_map ) as $k ) {
     22        if ( $fa_is_ignored( $k ) ) { unset( $folders_map[ $k ] ); }
    2123    }
    22 
    2324}
    2425
    2526// Remove from the simple list of disk slugs (used in cards/counts)
    26 
    27 if (isset($disk_slugs) && is_array($disk_slugs)) {
    28 
    29     $disk_slugs = array_values(array_filter($disk_slugs, function($s) use ($fa_eq,$fa_ignore_slug){
    30 
    31         return ! $fa_eq($s, $fa_ignore_slug);
    32 
    33     }));
    34 
     27if ( isset( $disk_slugs ) && is_array( $disk_slugs ) ) {
     28    $disk_slugs = array_values( array_filter( $disk_slugs, function( $s ) use ( $fa_is_ignored ) {
     29        return ! $fa_is_ignored( $s );
     30    } ) );
    3531}
    3632
    3733// Remove from orphan list (used in the “not showing as installed” table/card)
    38 
    39 if (isset($orphan_folders) && is_array($orphan_folders)) {
    40 
    41     $orphan_folders = array_values(array_filter($orphan_folders, function($s) use ($fa_eq,$fa_ignore_slug){
    42 
    43         return ! $fa_eq($s, $fa_ignore_slug);
    44 
    45     }));
    46 
     34if ( isset( $orphan_folders ) && is_array( $orphan_folders ) ) {
     35    $orphan_folders = array_values( array_filter( $orphan_folders, function( $s ) use ( $fa_is_ignored ) {
     36        return ! $fa_is_ignored( $s );
     37    } ) );
    4738}
    4839
    4940// Remove from installed-plugins table rows and update count
    50 
    51 if (isset($plugin_rows) && is_array($plugin_rows)) {
    52 
    53     $plugin_rows = array_values(array_filter($plugin_rows, function($row) use ($fa_eq,$fa_ignore_slug){
    54 
    55         return empty($row['folder_slug']) || ! $fa_eq($row['folder_slug'], $fa_ignore_slug);
    56 
    57     }));
    58 
    59     $total_plugins = count($plugin_rows);
    60 
     41if ( isset( $plugin_rows ) && is_array( $plugin_rows ) ) {
     42    $plugin_rows = array_values( array_filter( $plugin_rows, function( $row ) use ( $fa_is_ignored ) {
     43        return empty( $row['folder_slug'] ) || ! $fa_is_ignored( $row['folder_slug'] );
     44    } ) );
     45    $total_plugins = count( $plugin_rows );
    6146}
    6247
    6348// Put active plugins at the top, then sort A→Z by name (then folder slug)
    64 
    6549if ( ! empty( $plugin_rows ) && is_array( $plugin_rows ) ) {
    6650
    6751    // Build a fast lookup of active plugins (site + network)
    68 
    6952    $active_site     = (array) get_option( 'active_plugins', array() );
    70 
    7153    $network_active  = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) : array();
    72 
    7354    $active_lookup   = array_fill_keys( array_merge( $active_site, $network_active ), true );
    7455
    7556    usort( $plugin_rows, function( $a, $b ) use ( $active_lookup ) {
    76 
    77         $a_key = isset( $a['plugin_file'] ) ? (string) $a['plugin_file'] : '';
    78 
    79         $b_key = isset( $b['plugin_file'] ) ? (string) $b['plugin_file'] : '';
    80 
     57        $a_key    = isset( $a['plugin_file'] ) ? (string) $a['plugin_file'] : '';
     58        $b_key    = isset( $b['plugin_file'] ) ? (string) $b['plugin_file'] : '';
    8159        $a_active = isset( $active_lookup[ $a_key ] );
    82 
    8360        $b_active = isset( $active_lookup[ $b_key ] );
    8461
    8562        // Active first
    86 
    8763        if ( $a_active !== $b_active ) {
    88 
    8964            return $a_active ? -1 : 1;
    90 
    9165        }
    9266
    9367        // Then by display name (A→Z)
    94 
    95         $an = isset( $a['name'] ) ? (string) $a['name'] : '';
    96 
    97         $bn = isset( $b['name'] ) ? (string) $b['name'] : '';
    98 
     68        $an  = isset( $a['name'] ) ? (string) $a['name'] : '';
     69        $bn  = isset( $b['name'] ) ? (string) $b['name'] : '';
    9970        $cmp = strcasecmp( $an, $bn );
    100 
    10171        if ( 0 !== $cmp ) {
    102 
    10372            return $cmp;
    104 
    10573        }
    10674
    10775        // Stable fallback by folder slug
    108 
    10976        $aslug = isset( $a['folder_slug'] ) ? (string) $a['folder_slug'] : '';
    110 
    11177        $bslug = isset( $b['folder_slug'] ) ? (string) $b['folder_slug'] : '';
    112 
    11378        return strcasecmp( $aslug, $bslug );
    114 
    115     });
    116 
     79    } );
    11780}
    11881
    11982// Build list of files directly in plugins root that are NOT registered as plugins
    120 
    12183if ( ! isset( $plugins_root_files ) ) {
    122 
    12384    $plugins_root_files = [];
    124 
    12585    try {
    126 
    12786        if ( is_dir( WP_PLUGIN_DIR ) && is_readable( WP_PLUGIN_DIR ) ) {
    128 
    12987            $it = new DirectoryIterator( WP_PLUGIN_DIR );
    130 
    13188            foreach ( $it as $fi ) {
    132 
    13389                if ( $fi->isFile() ) { // root-only files
    134 
    13590                    $plugins_root_files[] = $fi->getFilename();
    136 
    13791                }
    138 
    13992            }
    140 
    14193        }
    142 
    14394    } catch ( Exception $e ) {}
    14495
    14596    // Remove any single-file plugins WP already knows about
    146 
    14797    if ( ! empty( $plugin_rows ) ) {
    148 
    14998        foreach ( $plugin_rows as $row ) {
    150 
    15199            if ( isset( $row['folder_slug'] ) && $row['folder_slug'] === '.' ) {
    152 
    153100                $plugins_root_files = array_values(
    154 
    155101                    array_diff( $plugins_root_files, [ basename( $row['plugin_file'] ) ] )
    156 
    157102                );
    158 
    159103            }
    160 
    161104        }
    162 
    163105    }
    164 
    165106}
    166107
    167108// Count for the card
    168 
    169109$plugins_root_files_count = is_array( $plugins_root_files ) ? count( $plugins_root_files ) : 0;
    170110
  • folder-auditor/trunk/includes/views/view-uploads.php

    r3374418 r3381342  
    7474
    7575    }
     76   
     77    // Filter out a specific file so it never shows up anywhere downstream
     78$uploads_php_hits = array_values(array_filter(
     79    (array) $uploads_php_hits,
     80    function( $hit ) {
     81        // Normalize path like "mainwp/index.php"
     82        $rel = isset($hit['path']) ? ltrim(str_replace('\\','/',$hit['path']), '/'): '';
     83        // Only skip this exact file
     84        return strtolower($rel) !== 'mainwp/index.php';
     85    }
     86));
    7687
    7788}
Note: See TracChangeset for help on using the changeset viewer.