PHP 输出网站目录树结构,目录树浏览器 – 安全地显示指定文件夹的树形结构

目录树浏览器 – 安全地显示指定文件夹的树形结构

20260320154932124-image

 

使用方法:
1. 将本文件放置于网站根目录,命名为 tree.php 或其他名称
2. 修改下方的 TARGET_BASE 常量,设置您要浏览的根文件夹(相对于网站根目录)
3. 通过浏览器访问:http://您的网站/tree.php
4.可选参数 ?dir=子文件夹路径 来浏览子目录,例如:?dir=images/icons

安全特性:
– 所有路径均经过 realpath 验证,防止目录遍历攻击
– 只允许访问 TARGET_BASE 目录及其子目录
– 输出使用 htmlspecialchars 转义,防止 XSS 攻击

<?php
/**
 * 目录树浏览器(文本风格)- 使用树形连线符号代替缩进空格
 * 
 * 使用方法:
 * 1. 将本文件放置于网站根目录,命名为 tree.php 或其他名称
 * 2. 修改下方的 TARGET_BASE 常量,设置您要浏览的根文件夹(相对于网站根目录)
 * 3. 通过浏览器访问:http://您的网站/tree.php
 * 4. 可选参数 ?dir=子文件夹路径 来浏览子目录,例如:?dir=images/icons
 * 
 * 安全特性:
 * - 所有路径均经过 realpath 验证,防止目录遍历攻击
 * - 只允许访问 TARGET_BASE 目录及其子目录
 * - 输出使用 htmlspecialchars 转义,防止 XSS 攻击
 */

// ---------- 配置区域 ----------
// 定义要浏览的根文件夹(相对于网站根目录 $_SERVER['DOCUMENT_ROOT'])
// 例如:'storage' 表示浏览 /网站根目录/storage
define('TARGET_BASE', '');  // 请修改为您需要显示的文件夹名称

// 是否显示隐藏文件(以点开头的文件/文件夹)
define('SHOW_HIDDEN', false);

// 图标定义(仍保留,使输出更友好)
define('ICON_FOLDER', '📁');
define('ICON_FILE', '📄');
// -----------------------------

// 设置时区与编码
date_default_timezone_set('Asia/Shanghai');
header('Content-Type: text/html; charset=utf-8');

// 基础路径:网站根目录下的目标文件夹
$basePath = rtrim($_SERVER['DOCUMENT_ROOT'], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . TARGET_BASE;
$baseReal = realpath($basePath);

// 检查基目录是否存在且可读
if ($baseReal === false) {
    die("<h2>错误:基目录不存在或不可读</h2><p>请检查配置:<code>" . htmlspecialchars($basePath) . "</code></p>");
}

// 获取请求的子目录参数,并清理
$requestDir = isset($_GET['dir']) ? trim($_GET['dir'], '/\\ ') : '';
$requestDir = str_replace(['../', '..\\'], '', $requestDir); // 额外过滤,但主要依赖 realpath

// 构建完整目标路径
if ($requestDir !== '') {
    // 替换目录分隔符为系统分隔符
    $requestDir = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $requestDir);
    $targetPath = $baseReal . DIRECTORY_SEPARATOR . $requestDir;
} else {
    $targetPath = $baseReal;
}

// 获取目标路径的真实路径(必须存在)
$targetReal = realpath($targetPath);
if ($targetReal === false) {
    die("<h2>错误:请求的路径不存在或无法访问</h2><p>路径:" . htmlspecialchars($targetPath) . "</p>");
}

// 安全检查:确保目标路径在基目录之下
$baseRealWithSep = $baseReal . DIRECTORY_SEPARATOR;
if (strpos($targetReal . DIRECTORY_SEPARATOR, $baseRealWithSep) !== 0 && $targetReal !== $baseReal) {
    die("<h2>错误:无权访问指定目录</h2>");
}

// 获取当前目录相对于基目录的路径(用于URL)
$relativePath = ($targetReal === $baseReal) ? '' : substr($targetReal, strlen($baseReal) + 1);
$relativePathForUrl = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);

/**
 * 递归生成文本风格的目录树(带连线符号)
 *
 * @param string $path      绝对路径
 * @param string $prefix    前缀字符串(用于递归构建连线)
 * @return array            每行文本组成的数组
 */
function buildTreeLines($path, $prefix = '') {
    $lines = [];
    $items = @scandir($path);
    if ($items === false) {
        return ['<无法读取目录>'];
    }

    // 过滤掉 . 和 ..
    $items = array_diff($items, ['.', '..']);
    
    // 如果不显示隐藏文件,过滤以点开头的项目
    if (!SHOW_HIDDEN) {
        $items = array_filter($items, function($item) {
            return $item[0] !== '.';
        });
    }

    // 自然排序:文件夹在前,文件在后,各自按名称排序
    $folders = [];
    $files   = [];
    foreach ($items as $item) {
        $fullPath = $path . DIRECTORY_SEPARATOR . $item;
        if (is_dir($fullPath)) {
            $folders[] = $item;
        } else {
            $files[] = $item;
        }
    }
    natcasesort($folders);
    natcasesort($files);
    $sortedItems = array_merge($folders, $files);

    $count = count($sortedItems);
    $i = 0;
    foreach ($sortedItems as $item) {
        $i++;
        $isLast = ($i === $count);
        $fullPath = $path . DIRECTORY_SEPARATOR . $item;
        $safeName = htmlspecialchars($item);
        
        // 选择连接符
        $connector = $isLast ? '└── ' : '├── ';
        
        if (is_dir($fullPath)) {
            // 文件夹行
            $lines[] = $prefix . $connector . ICON_FOLDER . ' ' . $safeName;
            // 递归子文件夹,生成新的前缀
            $subPrefix = $prefix . ($isLast ? '    ' : '│   ');
            $subLines = buildTreeLines($fullPath, $subPrefix);
            $lines = array_merge($lines, $subLines);
        } else {
            // 文件行:附加文件大小
            $size = filesize($fullPath);
            $sizeFormatted = formatBytes($size);
            $lines[] = $prefix . $connector . ICON_FILE . ' ' . $safeName . ' (' . $sizeFormatted . ')';
        }
    }
    return $lines;
}

/**
 * 格式化字节数
 */
function formatBytes($bytes, $precision = 2) {
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
    $bytes = max($bytes, 0);
    $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
    $pow = min($pow, count($units) - 1);
    $bytes /= (1 << (10 * $pow));
    return round($bytes, $precision) . ' ' . $units[$pow];
}

/**
 * 获取父目录的相对路径(用于返回上一级)
 */
function getParentRelative($currentRelative) {
    if (empty($currentRelative)) {
        return null; // 已经在根目录,无上级
    }
    $parts = explode(DIRECTORY_SEPARATOR, $currentRelative);
    array_pop($parts);
    return implode(DIRECTORY_SEPARATOR, $parts);
}

// 生成当前目录的文本树行数组
$treeLines = buildTreeLines($targetReal);
$treeText = implode("\n", $treeLines);

// 获取当前目录名称(用于显示)
$currentDisplayName = ($targetReal === $baseReal) ? TARGET_BASE . ' (根)' : basename($targetReal);
?>
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>目录树浏览器(文本风格) - <?php echo htmlspecialchars($currentDisplayName); ?></title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', 'SF Pro Text', 'Helvetica Neue', sans-serif;
            background: #f8f9fa;
            margin: 0;
            padding: 20px;
            color: #2c3e50;
        }
        .container {
            max-width: 1200px;
            margin: 0 auto;
            background: white;
            border-radius: 16px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.1);
            overflow: hidden;
            padding: 25px 30px;
        }
        h1 {
            margin-top: 0;
            font-size: 1.8rem;
            font-weight: 400;
            border-bottom: 1px solid #e9ecef;
            padding-bottom: 15px;
            color: #1e3c5c;
        }
        h1 small {
            font-size: 0.9rem;
            font-weight: 300;
            color: #6c757d;
            margin-left: 10px;
        }
        .path-bar {
            background: #e9ecef;
            border-radius: 30px;
            padding: 10px 20px;
            margin-bottom: 25px;
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
            font-size: 0.95rem;
        }
        .path-label {
            font-weight: 600;
            color: #495057;
            margin-right: 5px;
        }
        .path-links {
            display: flex;
            flex-wrap: wrap;
            align-items: center;
            gap: 5px;
        }
        .path-links a {
            color: #0d6efd;
            text-decoration: none;
            padding: 2px 8px;
            border-radius: 20px;
            background: white;
            border: 1px solid #dee2e6;
            transition: 0.2s;
        }
        .path-links a:hover {
            background: #0d6efd;
            color: white;
            border-color: #0d6efd;
        }
        .path-links span.sep {
            color: #adb5bd;
            margin: 0 2px;
        }
        .nav-form {
            margin-left: auto;
            display: flex;
            gap: 8px;
        }
        .nav-form input {
            padding: 8px 15px;
            border-radius: 30px;
            border: 1px solid #ced4da;
            width: 250px;
            font-size: 0.9rem;
        }
        .nav-form button {
            background: #0d6efd;
            color: white;
            border: none;
            border-radius: 30px;
            padding: 8px 20px;
            cursor: pointer;
            font-weight: 500;
            transition: 0.2s;
        }
        .nav-form button:hover {
            background: #0b5ed7;
        }
        /* 文本树样式 */
        .tree-container {
            background: #fefefe;
            border-radius: 12px;
            padding: 20px;
            font-family: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
            font-size: 1rem;
            line-height: 1.5;
            overflow-x: auto;
            border: 1px solid #e9ecef;
            white-space: pre;
            margin: 15px 0;
        }
        .tree-text {
            margin: 0;
            color: #212529;
        }
        .footer {
            margin-top: 30px;
            text-align: center;
            font-size: 0.85rem;
            color: #6c757d;
            border-top: 1px solid #e9ecef;
            padding-top: 15px;
        }
        .footer code {
            background: #e9ecef;
            padding: 2px 6px;
            border-radius: 6px;
        }
        .parent-link {
            display: inline-block;
            background: #e7f3ff;
            color: #0d6efd;
            padding: 8px 18px;
            border-radius: 30px;
            text-decoration: none;
            font-weight: 500;
            border: 1px solid #b8daff;
            transition: 0.2s;
        }
        .parent-link:hover {
            background: #0d6efd;
            color: white;
            border-color: #0d6efd;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>
            📂 目录树浏览器(文本风格)
            <small><?php echo htmlspecialchars(TARGET_BASE); ?> 及其子目录</small>
        </h1>

        <!-- 路径导航 -->
        <div class="path-bar">
            <span class="path-label">当前位置:</span>
            <div class="path-links">
                <a href="?">根目录</a>
                <?php
                // 生成面包屑导航
                if (!empty($relativePath)) {
                    $parts = explode(DIRECTORY_SEPARATOR, $relativePath);
                    $cumulative = '';
                    echo '<span class="sep">/</span>';
                    foreach ($parts as $index => $part) {
                        $cumulative .= ($cumulative ? DIRECTORY_SEPARATOR : '') . $part;
                        $safePart = htmlspecialchars($part);
                        $url = '?dir=' . urlencode(str_replace(DIRECTORY_SEPARATOR, '/', $cumulative));
                        if ($index === count($parts) - 1) {
                            echo '<span>' . $safePart . '</span>';
                        } else {
                            echo '<a href="' . $url . '">' . $safePart . '</a><span class="sep">/</span>';
                        }
                    }
                }
                ?>
            </div>

            <!-- 快速跳转子目录表单 -->
            <form class="nav-form" method="get" action="">
                <input type="text" name="dir" placeholder="输入子目录路径 (如 images/icons)" value="<?php echo htmlspecialchars($relativePathForUrl); ?>">
                <button type="submit">跳转</button>
            </form>
        </div>

        <!-- 文本树输出 -->
        <div class="tree-container">
            <pre class="tree-text"><?php
                // 输出根目录行,然后输出树内容
                echo ICON_FOLDER . ' ' . htmlspecialchars($currentDisplayName) . "\n";
                echo $treeText;
            ?></pre>
        </div>

        <!-- 父目录链接与说明 -->
        <div style="margin-top: 20px;">
            <?php
            $parentRelative = getParentRelative($relativePath);
            if ($parentRelative !== null):
                $parentUrl = '?dir=' . urlencode(str_replace(DIRECTORY_SEPARATOR, '/', $parentRelative));
            ?>
                <a href="<?php echo $parentUrl; ?>" class="parent-link">⬆ 返回上一级 (<?php echo htmlspecialchars(basename($parentRelative) ?: '根目录'); ?>)</a>
            <?php endif; ?>
        </div>

        <div class="footer">
            <p>🔒 安全限制:只能浏览 <code><?php echo htmlspecialchars($baseReal); ?></code> 及其子目录 | 隐藏文件<?php echo SHOW_HIDDEN ? '显示' : '不显示'; ?> | 连线符号: ├── └── │</p>
        </div>
    </div>
</body>
</html>
© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容