# Kill Apache/LiteSpeed directory listings on the WordPress uploads tree.
#
# WHY (measured live 2026-10-02 on waterfilter.my.id):
# /wp-content/uploads/ -> 200,
Index of /wp-content/uploads/
# /wp-content/uploads/2025/ -> 200, Index of /wp-content/uploads/2025/
# /wp-content/uploads/2025/01/ -> 200, Index of /wp-content/uploads/2025/01/
# Server: LiteSpeed.
#
# Semrush counted 464 pages with "low text-HTML ratio" and 921
# "resources formatted as page links" on this domain. The listing pages ARE
# those resources: a crawled, indexable, text-free HTML page full of file
# links. They also expose every uploaded filename, which is a reconnaissance
# gift and was the actual cause of the 921-error spike.
#
# HOW: two independent measures, because either one alone has a failure mode.
# 1. .htaccess -> Options -Indexes stops the listing at the web server.
# 2. index.html -> a real 200 with content, so even if .htaccess is ignored
# (LiteSpeed per-directory config, nginx-style front end) the
# URL stops being a thin text-free page.
# The index.html must NOT be a redirect to the homepage -- that turns a
# directory URL into a duplicate of / and creates a soft-404 loop.
if (!defined('ABSPATH')) {
exit;
}
if (!function_exists('wfh_apply_no_listing')) {
/**
* Do the actual work.
*
* v1.0 hung this on `admin_init`, which fires on neither a front-end request
* nor a `wp eval` call. The file deployed, linted, matched md5, left every
* page at 200 -- and fixed nothing. A green deploy that changes nothing is
* the worst outcome an SEO fix can have, so the work now runs at load.
*/
function wfh_apply_no_listing()
{
// --- 1. .htaccess guard -----------------------------------------------
$ht = ABSPATH . '.htaccess';
if (file_exists($ht)) {
$body = file_get_contents($ht);
if ($body !== false && strpos($body, 'WFH_NO_INDEXES') === false) {
$guard = "\n# WFH_NO_INDEXES -- stop directory listings on the uploads tree.\n"
. "# These were crawled as text-free pages (Semrush: 464 low text-HTML\n"
. "# ratio, 921 resources-as-page-links on waterfilter.my.id).\n"
. "\n"
. " Options -Indexes\n"
. "\n"
. "\n"
. " Options -Indexes\n"
. "\n";
file_put_contents($ht, $body . $guard, LOCK_EX);
}
}
// --- 2. index.html across the uploads tree ----------------------------
$uploads = wp_upload_dir();
if (!empty($uploads['error']) || empty($uploads['basedir'])) {
return;
}
$base = trailingslashit($uploads['basedir']);
if (!is_dir($base)) {
return;
}
// A listed URL must stop being a text-free page, so the file carries
// real copy rather than being empty. It must NOT redirect to the
// homepage: that turns a directory URL into a duplicate of "/".
$html = "\n\n"
. "403 Forbidden\n"
. "\n"
. "403 Forbidden
\n"
. "Directory access is disabled.
\n";
$targets = [$base];
foreach ((array) glob($base . '*', GLOB_ONLYDIR) as $y) {
$targets[] = $y;
foreach ((array) glob($y . '/*', GLOB_ONLYDIR) as $m) {
$targets[] = $m;
}
}
foreach ($targets as $dir) {
$f = rtrim($dir, '/') . '/index.html';
if (!file_exists($f)) {
@file_put_contents($f, $html, LOCK_EX);
}
}
}
}
// Run now, on every load. Both writes are existence-guarded, so steady-state
// cost is one file read.
wfh_apply_no_listing();
// Keep folders created later covered too.
add_action('wp_insert_attachment', 'wfh_apply_no_listing');