Python3.14编写文件服务器

成品展示

输入 http://127.0.0.1:8080/,展示截图如下

代码如下

python 复制代码
# -*- coding: utf-8 -*-
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import quote, unquote
import zipfile
from io import BytesIO

# 配置
HOST = "0.0.0.0"
PORT = 8080
IMG_DIR = "img"
# 仅这些格式支持页面预览+大图弹窗
PREVIEW_SUFFIX = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff")
# 统一正斜杠根路径
BASE_IMG_URL = IMG_DIR.replace(os.sep, "/")
os.makedirs(IMG_DIR, exist_ok=True)


class ImageHandler(BaseHTTPRequestHandler):
    # 过滤NiceGUI websocket刷屏日志,其余访问日志正常输出
    def log_message(self, format, *args):
        msg = format % args
        if "_nicegui_ws" not in msg:
            print(msg)

    def read_file(self, path):
        with open(path, "rb") as f:
            return f.read()

    # 安全路径校验,防目录穿越,统一正斜杠
    def safe_abs_path(self, rel_path):
        base_abs = os.path.abspath(IMG_DIR)
        # 统一转为正斜杠拼接
        target_rel = os.path.join(BASE_IMG_URL, rel_path).replace(os.sep, "/")
        target_abs = os.path.abspath(target_rel.replace("/", os.sep))
        if not target_abs.startswith(base_abs):
            return None
        return target_abs

    # 递归生成左侧树形目录HTML
    def build_tree_html(self, current_rel, parent_rel=""):
        abs_dir = self.safe_abs_path(parent_rel)
        if abs_dir is None or not os.path.isdir(abs_dir):
            return ""
        items = []
        for name in sorted(os.listdir(abs_dir)):
            full_path = os.path.join(abs_dir, name)
            if os.path.isdir(full_path):
                sub_rel = f"{parent_rel}/{name}".strip("/")
                child_html = self.build_tree_html(current_rel, sub_rel)
                active_cls = "tree-active" if sub_rel == current_rel else ""
                items.append(f"""
                <div class="tree-item">
                    <div class="tree-line">
                        <a class="tree-link {active_cls}" href="/list/{quote(sub_rel)}">📂 {name}</a>
                        <a class="tree-zip-btn" href="javascript:startZipDownload('{quote(sub_rel)}')" title="批量下载该目录全部文件">↓打包</a>
                    </div>
                    <div class="tree-child">{child_html}</div>
                </div>
                """)
        return "".join(items)

    # 解析URL获取当前浏览目录(无分隔符替换)
    def get_current_dir(self):
        if self.path == "/":
            return ""
        if self.path.startswith("/list/"):
            raw = unquote(self.path[6:])
            return raw.strip("/")
        return None

    # 递归写入zip内存流
    def add_dir_to_zip(self, zf, root_rel, curr_rel):
        curr_abs = self.safe_abs_path(curr_rel)
        if not curr_abs:
            return 0
        count = 0
        for name in os.listdir(curr_abs):
            file_abs = os.path.join(curr_abs, name)
            file_rel = f"{curr_rel}/{name}".strip("/")
            zip_inner_path = file_rel[len(root_rel):].lstrip("/")
            if os.path.isdir(file_abs):
                cnt = self.add_dir_to_zip(zf, root_rel, file_rel)
                count += cnt
            elif os.path.isfile(file_abs):
                zf.write(file_abs, arcname=zip_inner_path)
                count += 1
        return count

    # 整目录打包下载接口
    def handle_zip_download(self, rel_dir):
        target_abs = self.safe_abs_path(rel_dir)
        if target_abs is None or not os.path.isdir(target_abs):
            self.send_response(404)
            self.send_header("Content-Type", "text/plain;charset=utf-8")
            self.end_headers()
            self.wfile.write("目录不存在,无法打包".encode("utf-8"))
            return

        dir_name = os.path.basename(target_abs) if rel_dir else "根目录文件"
        mem_buf = BytesIO()
        with zipfile.ZipFile(mem_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
            file_count = self.add_dir_to_zip(zf, rel_dir, rel_dir)
        if file_count == 0:
            self.send_response(400)
            self.send_header("Content-Type", "text/plain;charset=utf-8")
            self.end_headers()
            self.wfile.write("该目录下无任何文件,无法打包".encode("utf-8"))
            return
        mem_buf.seek(0)
        zip_data = mem_buf.read()

        self.send_response(200)
        self.send_header("Content-Type", "application/zip")
        self.send_header("Content-Disposition", f'attachment; filename="{dir_name}.zip"')
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Content-Length", str(len(zip_data)))
        self.end_headers()
        self.wfile.write(zip_data)

    # 渲染页面(卡片缩小一半、按钮宽高减半CSS完整保留)
    def render_split_page(self, rel_dir):
        abs_dir = self.safe_abs_path(rel_dir)
        tree_html = self.build_tree_html(rel_dir)
        root_active = "tree-active" if rel_dir == "" else ""

        folders = []
        all_files = []
        if abs_dir and os.path.isdir(abs_dir):
            for name in os.listdir(abs_dir):
                full_path = os.path.join(abs_dir, name)
                if os.path.isdir(full_path):
                    folders.append(name)
                elif os.path.isfile(full_path):
                    all_files.append(name)
        else:
            return "<h1>403/404 目录不存在</h1>".encode("utf-8")

        html = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>文件浏览下载服务</title>
    <style>
        * {{margin:0;padding:0;box-sizing:border-box;font-family:"Microsoft Yahei",Arial;}}
        body {{display:flex;height:100vh;overflow:hidden;background:#f5f7fa;}}
        /* 打包加载遮罩 */
        .loading-mask {{
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            background: rgba(0,0,0,0.6);
            z-index: 9999;
            align-items: center;
            justify-content: center;
        }}
        .loading-box {{
            background: #fff;
            padding: 30px 40px;
            border-radius: 10px;
            font-size: 16px;
            color: #222;
        }}
        /* 左侧目录侧边栏 */
        .sidebar {{
            width: 280px;
            min-width:280px;
            background:#fff;
            border-right:1px solid #ddd;
            padding:20px 10px;
            overflow-y:auto;
        }}
        .sidebar h3 {{text-align:center;margin-bottom:20px;color:#222;}}
        .tree-item {{margin:6px 0;padding-left:12px;}}
        .tree-child {{padding-left:16px;}}
        .tree-line {{display:flex;align-items:center;gap:6px;}}
        .tree-link {{
            flex:1;
            display:block;
            padding:6px 8px;
            text-decoration:none;
            color:#333;
            border-radius:4px;
        }}
        .tree-link:hover {{background:#e8f0fe;}}
        .tree-active {{background:#2478ff;color:#fff !important;}}
        .tree-zip-btn {{
            font-size:10px;
            padding:3px 5px;
            background:#00aa66;
            color:#fff;
            text-decoration:none;
            border-radius:3px;
            white-space:nowrap;
        }}
        .tree-zip-btn:hover {{background:#008850;}}
        /* 右侧内容区域 */
        .main-content {{
            flex:1;
            overflow-y:auto;
            padding:30px;
        }}
        h2 {{text-align:center;color:#222;margin-bottom:10px;}}
        .tip {{text-align:center;color:#d32f2f;margin-bottom:20px;}}
        .section-title {{
            font-size:18px;
            margin:30px 0 12px;
            color:#333;
            border-left:4px solid #2478ff;
            padding-left:10px;
        }}
        /* 卡片整体缩小一半 minmax(120px,1fr) */
        .grid {{
            display:grid;
            grid-template-columns:repeat(auto-fill,minmax(120px,1fr));
            gap:14px;
            margin-bottom:40px;
        }}
        .folder-card {{
            background:#fff;
            padding:14px;
            border-radius:8px;
            box-shadow:0 2px 8px #ddd;
            text-align:center;
        }}
        .folder-card a {{
            display:block;
            text-decoration:none;
            color:#2478ff;
            font-size:14px;
            padding:8px 0;
        }}
        .file-card {{
            background:#fff;
            padding:6px;
            border-radius:8px;
            box-shadow:0 1px 6px #ddd;
        }}
        /* 缩略图同步缩小一半 height:52px */
        .preview-box {{
            width:100%;
            height:52px;
            display:flex;
            align-items:center;
            justify-content:center;
            background:#eee;
            margin-bottom:4px;
        }}
        .preview-box img {{max-width:100%;max-height:100%;object-fit:contain;}}
        .file-txt {{color:#666;font-size:10px;text-align:center;padding:0 2px;}}
        /* 按钮整体宽度缩小一半,水平居中 */
        .btn-row {{
            display:grid;
            grid-template-columns: 1fr 1fr;
            gap:0;
            margin-top:4px;
            width:50%;
            margin-left:auto;
            margin-right:auto;
        }}
        .zoom-btn {{
            border-radius:4px 0 0 4px;
        }}
        .download-btn {{
            border-radius:0 4px 4px 0;
        }}
        /* 按钮高度缩小一半,字号缩小 */
        .download-btn, .zoom-btn {{
            display:block;
            text-align:center;
            padding:2px 0;
            color:#fff;
            text-decoration:none;
            font-size:9px;
            border:none;
            cursor:pointer;
        }}
        .download-btn {{background:#2478ff;}}
        .download-btn:hover {{background:#0f60e0;}}
        .zoom-btn {{background:#22aa55;}}
        .zoom-btn:hover {{background:#188844;}}

        /* 大图弹窗模态框 */
        .modal {{
            display:none;
            position:fixed;
            top:0;
            left:0;
            width:100vw;
            height:100vh;
            background:rgba(0,0,0,0.85);
            z-index:998;
            align-items:center;
            justify-content:center;
            padding:30px;
        }}
        .modal-content {{
            max-width:95%;
            max-height:95%;
            position:relative;
        }}
        .modal-img {{
            max-width:100%;
            max-height:90vh;
            border:4px solid #fff;
        }}
        .modal-close {{
            position:absolute;
            top:-30px;
            right:-30px;
            color:#fff;
            font-size:32px;
            cursor:pointer;
        }}
    </style>
</head>
<body>
    <div class="loading-mask" id="loadingMask">
        <div class="loading-box">⏳ 正在打包压缩,请稍候...</div>
    </div>

    <div class="modal" id="imgModal" onclick="closeModal(event)">
        <div class="modal-content">
            <span class="modal-close" onclick="closeModal()">&times;</span>
            <img id="modalImage" class="modal-img" src="" alt="大图预览">
        </div>
    </div>

    <div class="sidebar">
        <h3>📁 全部目录 <span style="font-size:10px;color:#666;">(↓打包=批量下载)</span></h3>
        <div class="tree-item">
            <div class="tree-line">
                <a class="tree-link {root_active}" href="/list/">🏠 根目录(img/)</a>
                <a class="tree-zip-btn" href="javascript:startZipDownload('')" title="批量下载根目录全部文件">↓打包</a>
            </div>
            <div class="tree-child">
                {tree_html}
            </div>
        </div>
    </div>

    <div class="main-content">
        <h2>当前目录:{rel_dir if rel_dir else "根目录 img/"}</h2>
        <div class="tip">提示:仅图片文件可在线预览/放大,所有格式文件均可下载;tif/tiff浏览器无法预览大图;左侧目录点击↓打包可批量下载整个目录,打包期间会显示加载提示</div>

        <div class="section-title">📂 子文件夹</div>
        <div class="grid">
"""
        for fname in folders:
            sub_rel = f"{rel_dir}/{fname}".strip("/")
            url = "/list/" + quote(sub_rel)
            html += f'<div class="folder-card"><a href="{url}">{fname}</a></div>'
        html += "</div><div class='section-title'>📄 当前目录全部文件</div><div class='grid'>"

        for file_name in all_files:
            file_rel = f"{rel_dir}/{file_name}".strip("/")
            safe_file = quote(file_rel)
            lower_name = file_name.lower()
            is_previewable = lower_name.endswith(PREVIEW_SUFFIX)

            if is_previewable:
                if lower_name.endswith((".tif", ".tiff")):
                    preview_html = '<div class="preview-box"><div class="file-txt">TIFF</div></div>'
                    btn_html = f'<a class="download-btn" href="/download/{safe_file}">下载</a>'
                else:
                    preview_html = f'<div class="preview-box"><img src="/img/{safe_file}" alt="{file_name}"></div>'
                    btn_html = f"""
                    <div class="btn-row">
                        <button class="zoom-btn" onclick="openModal('/img/{safe_file}')">放大</button>
                        <a class="download-btn" href="/download/{safe_file}">下载</a>
                    </div>
                    """
            else:
                preview_html = '<div class="preview-box"><div class="file-txt">文件</div></div>'
                btn_html = f'<a class="download-btn" href="/download/{safe_file}">下载</a>'

            html += f"""
            <div class="file-card">
                {preview_html}
                <div style="font-size:9px;word-break:break-all;margin:2px 0;">{file_name}</div>
                {btn_html}
            </div>
"""
        html += """
        </div>
    </div>

    <script>
        const loadingMask = document.getElementById('loadingMask');
        function startZipDownload(dirPath) {
            loadingMask.style.display = 'flex';
            let zipUrl;
            if(dirPath === ''){
                zipUrl = `/zip`;
            }else{
                zipUrl = `/zip/${dirPath}`;
            }
            const newWin = window.open(zipUrl, '_blank');
            if (!newWin || newWin.closed || typeof newWin.closed=='undefined') {
                loadingMask.style.display = 'none';
                alert("浏览器拦截了弹出窗口,请允许本站弹窗后重试");
                return;
            }
            setTimeout(()=>{
                loadingMask.style.display = 'none';
            }, 30000);
        }

        const modal = document.getElementById("imgModal");
        const modalImg = document.getElementById("modalImage");
        function openModal(src) {
            modal.style.display = "flex";
            modalImg.src = src;
        }
        function closeModal(e) {
            if (!e || e.target === modal) {
                modal.style.display = "none";
                modalImg.src = "";
            }
        }
        document.addEventListener('keydown', function(e){
            if(e.key === 'Escape') closeModal();
        })
    </script>
</body>
</html>
"""
        return html.encode("utf-8")

    def do_GET(self):
        # 首页自动跳转根目录列表
        if self.path == "/":
            self.send_response(302)
            self.send_header("Location", "/list/")
            self.end_headers()
            return

        # 目录打包接口 /zip /zip/xxx
        if self.path.startswith("/zip"):
            raw = self.path[4:].lstrip("/")
            dir_rel = unquote(raw).strip("/")
            self.handle_zip_download(dir_rel)
            return

        # 页面浏览 /list/xxx
        current_dir = self.get_current_dir()
        if current_dir is not None:
            page_data = self.render_split_page(current_dir)
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(page_data)
            return

        # 图片预览 /img/xxx
        if self.path.startswith("/img/"):
            rel_path = unquote(self.path[5:]).strip("/")
            abs_file = self.safe_abs_path(rel_path)
            if abs_file is None or not os.path.isfile(abs_file):
                self.send_response(404)
                self.end_headers()
                self.wfile.write("404 文件不存在".encode("utf-8"))
                return
            if not rel_path.lower().endswith(PREVIEW_SUFFIX):
                self.send_response(403)
                self.end_headers()
                self.wfile.write("403 仅图片可在线预览,请使用下载按钮获取文件".encode("utf-8"))
                return

            suffix = rel_path.lower()
            self.send_response(200)
            if suffix.endswith(".png"):
                self.send_header("Content-Type", "image/png")
            elif suffix.endswith(".gif"):
                self.send_header("Content-Type", "image/gif")
            elif suffix.endswith((".tif", ".tiff")):
                self.send_header("Content-Type", "image/tiff")
            else:
                self.send_header("Content-Type", "image/jpeg")
            self.end_headers()
            self.wfile.write(self.read_file(abs_file))
            return

        # 文件下载 /download/xxx
        if self.path.startswith("/download/"):
            rel_path = unquote(self.path[10:]).strip("/")
            abs_file = self.safe_abs_path(rel_path)
            if abs_file is None or not os.path.isfile(abs_file):
                self.send_response(404)
                self.end_headers()
                self.wfile.write("404 文件不存在".encode("utf-8"))
                return
            file_name = os.path.basename(abs_file)
            self.send_response(200)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
            self.end_headers()
            self.wfile.write(self.read_file(abs_file))
            return

        # 兜底404
        self.send_response(404)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.end_headers()
        self.wfile.write("404 Not Found".encode("utf-8"))


if __name__ == "__main__":
    server = HTTPServer((HOST, PORT), ImageHandler)
    print("===== 过滤NiceGUI websocket刷屏日志、保留正常访问日志 =====")
    print(f"访问地址:http://127.0.0.1:{PORT}")
    print("说明:")
    print("1. 自动过滤/_nicegui_ws/垃圾404日志,不再刷屏")
    print("2. 正常页面、图片、下载、打包请求日志照常打印,方便调试")
    print("3. 路径统一正斜杠,彻底修复Windows下文件404问题")
    print("Ctrl + C 停止服务")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.server_close()
        print("\n服务已正常关闭")
相关推荐
郭梧悠1 小时前
算法:有效的括号
python·算法·leetcode
佛珠散了一地2 小时前
ONNX Runtime GPU 推理配置指南
python
派葛穆2 小时前
Python-pip切换镜像源
开发语言·python·pip
CTA终结者2 小时前
2026年AI量化提效,工具重点要按阶段调整
人工智能·python
xxie1237942 小时前
Python 闭包:函数嵌套的 “状态捕获” 机制
开发语言·python
c_lb72883 小时前
最新AI量化提效,交易认知和技术实现要接上
人工智能·python
机汇五金_3 小时前
钣金外壳定制厂家助力设备升级
大数据·人工智能·python·物联网
xxie1237943 小时前
Python 闭包的调用方法与实践
开发语言·python
HZZD_HZZD3 小时前
用电行为异常检测VAE-基于PyTorch设计用电行为异常检测模型:从时序特征提取到变分自编码器部署的完整实战
人工智能·pytorch·python