Skip to content

Node.js静态文件服务2024:Web开发者资源托管完整指南

📊 SEO元描述:2024年最新Node.js静态文件服务教程,详解静态资源托管、MIME类型处理、缓存策略。包含完整代码示例,适合Web开发者快速掌握文件服务技术。

核心关键词:Node.js静态文件服务2024、静态资源托管、MIME类型处理、Node.js文件服务器、Web缓存策略

长尾关键词:Node.js怎么托管静态文件、静态文件服务器搭建、MIME类型配置方法、Node.js缓存策略、静态资源优化技巧


📚 静态文件服务学习目标与核心收获

通过本节Node.js静态文件服务教程,你将系统性掌握:

  • 静态文件服务基础:理解静态文件服务的概念和在Web开发中的重要作用
  • 文件系统操作:掌握Node.js文件系统模块的使用和文件读取技巧
  • MIME类型处理:学会正确识别和设置各种文件类型的Content-Type
  • 缓存策略实现:实现HTTP缓存机制,提升网站性能和用户体验
  • 安全考虑:了解静态文件服务的安全风险和防护措施
  • 性能优化技巧:掌握静态文件服务的性能优化和最佳实践

🎯 适合人群

  • Node.js进阶学习者的Web服务器开发技能提升
  • 前端开发工程师的全栈技能扩展需求
  • Web开发初学者的静态资源管理入门
  • 运维工程师的文件服务器配置和优化

🌟 静态文件服务是什么?为什么对Web应用如此重要?

静态文件服务是什么?这是Web开发中的基础概念。静态文件服务是指为HTML、CSS、JavaScript、图片等静态资源提供HTTP访问的服务,也是Web应用性能的重要影响因素。

静态文件服务的核心特性

  • 🎯 资源托管:为各种静态资源提供HTTP访问接口
  • 🔧 MIME类型识别:根据文件扩展名自动设置正确的Content-Type
  • 💡 缓存支持:实现HTTP缓存机制,减少重复请求
  • 📚 目录浏览:可选的目录列表功能,便于资源管理
  • 🚀 性能优化:支持压缩、范围请求等性能优化特性

💡 设计理念:静态文件服务应该快速、可靠、安全,为用户提供最佳的资源加载体验

基础静态文件服务器实现

让我们从最简单的静态文件服务器开始:

javascript
// 🎉 基础静态文件服务器
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');

// 静态文件服务器类
class StaticFileServer {
    constructor(rootDir = './public', port = 3000) {
        this.rootDir = path.resolve(rootDir);
        this.port = port;
        this.ensureRootDir();
    }
    
    ensureRootDir() {
        if (!fs.existsSync(this.rootDir)) {
            fs.mkdirSync(this.rootDir, { recursive: true });
            console.log(`创建静态文件目录: ${this.rootDir}`);
        }
    }
    
    async serveFile(req, res) {
        try {
            const parsedUrl = url.parse(req.url);
            let pathname = parsedUrl.pathname;
            
            // 防止路径遍历攻击
            pathname = this.sanitizePath(pathname);
            
            // 构建完整文件路径
            const filePath = path.join(this.rootDir, pathname);
            
            // 检查文件是否存在
            if (!fs.existsSync(filePath)) {
                this.send404(res);
                return;
            }
            
            // 获取文件信息
            const stats = fs.statSync(filePath);
            
            if (stats.isDirectory()) {
                // 尝试查找默认文件
                const indexFile = this.findIndexFile(filePath);
                if (indexFile) {
                    await this.sendFile(res, indexFile);
                } else {
                    this.sendDirectoryListing(res, filePath, pathname);
                }
            } else {
                await this.sendFile(res, filePath);
            }
            
        } catch (error) {
            console.error('文件服务错误:', error);
            this.send500(res);
        }
    }
    
    sanitizePath(pathname) {
        // 解码URL编码
        pathname = decodeURIComponent(pathname);
        
        // 移除查询参数
        pathname = pathname.split('?')[0];
        
        // 规范化路径,防止路径遍历
        pathname = path.normalize(pathname);
        
        // 确保路径以/开头
        if (!pathname.startsWith('/')) {
            pathname = '/' + pathname;
        }
        
        // 防止访问上级目录
        if (pathname.includes('..')) {
            pathname = '/';
        }
        
        return pathname;
    }
    
    findIndexFile(dirPath) {
        const indexFiles = ['index.html', 'index.htm', 'default.html'];
        
        for (const indexFile of indexFiles) {
            const indexPath = path.join(dirPath, indexFile);
            if (fs.existsSync(indexPath)) {
                return indexPath;
            }
        }
        
        return null;
    }
    
    async sendFile(res, filePath) {
        try {
            const stats = fs.statSync(filePath);
            const mimeType = this.getMimeType(filePath);
            
            // 设置响应头
            res.setHeader('Content-Type', mimeType);
            res.setHeader('Content-Length', stats.size);
            res.setHeader('Last-Modified', stats.mtime.toUTCString());
            
            // 创建文件读取流
            const fileStream = fs.createReadStream(filePath);
            
            // 处理流错误
            fileStream.on('error', (error) => {
                console.error('文件读取错误:', error);
                this.send500(res);
            });
            
            // 发送文件
            res.writeHead(200);
            fileStream.pipe(res);
            
            console.log(`发送文件: ${filePath} (${mimeType})`);
            
        } catch (error) {
            console.error('发送文件错误:', error);
            this.send500(res);
        }
    }
    
    getMimeType(filePath) {
        const ext = path.extname(filePath).toLowerCase();
        const mimeTypes = {
            '.html': 'text/html; charset=utf-8',
            '.htm': 'text/html; charset=utf-8',
            '.css': 'text/css; charset=utf-8',
            '.js': 'application/javascript; charset=utf-8',
            '.json': 'application/json; charset=utf-8',
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.svg': 'image/svg+xml',
            '.ico': 'image/x-icon',
            '.txt': 'text/plain; charset=utf-8',
            '.pdf': 'application/pdf',
            '.zip': 'application/zip',
            '.mp4': 'video/mp4',
            '.mp3': 'audio/mpeg',
            '.woff': 'font/woff',
            '.woff2': 'font/woff2',
            '.ttf': 'font/ttf',
            '.eot': 'application/vnd.ms-fontobject'
        };
        
        return mimeTypes[ext] || 'application/octet-stream';
    }
    
    sendDirectoryListing(res, dirPath, urlPath) {
        try {
            const files = fs.readdirSync(dirPath);
            
            let html = `
                <!DOCTYPE html>
                <html>
                <head>
                    <title>目录列表 - ${urlPath}</title>
                    <meta charset="utf-8">
                    <style>
                        body { font-family: Arial, sans-serif; margin: 40px; }
                        .header { border-bottom: 1px solid #ccc; padding-bottom: 10px; }
                        .file-list { list-style: none; padding: 0; }
                        .file-item { padding: 8px 0; border-bottom: 1px solid #eee; }
                        .file-item a { text-decoration: none; color: #0066cc; }
                        .file-item a:hover { text-decoration: underline; }
                        .file-size { color: #666; font-size: 0.9em; margin-left: 10px; }
                        .directory { font-weight: bold; }
                    </style>
                </head>
                <body>
                    <div class="header">
                        <h1>目录列表</h1>
                        <p>路径: ${urlPath}</p>
                    </div>
                    <ul class="file-list">
            `;
            
            // 添加上级目录链接
            if (urlPath !== '/') {
                const parentPath = path.dirname(urlPath);
                html += `
                    <li class="file-item">
                        <a href="${parentPath}" class="directory">📁 ..</a>
                    </li>
                `;
            }
            
            // 添加文件和目录
            files.forEach(file => {
                const filePath = path.join(dirPath, file);
                const stats = fs.statSync(filePath);
                const isDirectory = stats.isDirectory();
                const fileUrl = path.posix.join(urlPath, file);
                const icon = isDirectory ? '📁' : '📄';
                const className = isDirectory ? 'directory' : '';
                const size = isDirectory ? '' : `<span class="file-size">(${this.formatFileSize(stats.size)})</span>`;
                
                html += `
                    <li class="file-item">
                        <a href="${fileUrl}" class="${className}">${icon} ${file}</a>
                        ${size}
                    </li>
                `;
            });
            
            html += `
                    </ul>
                </body>
                </html>
            `;
            
            res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
            res.end(html);
            
        } catch (error) {
            console.error('目录列表错误:', error);
            this.send500(res);
        }
    }
    
    formatFileSize(bytes) {
        if (bytes === 0) return '0 B';
        
        const k = 1024;
        const sizes = ['B', 'KB', 'MB', 'GB'];
        const i = Math.floor(Math.log(bytes) / Math.log(k));
        
        return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
    }
    
    send404(res) {
        res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`
            <!DOCTYPE html>
            <html>
            <head>
                <title>404 - 文件未找到</title>
                <meta charset="utf-8">
            </head>
            <body>
                <h1>404 - 文件未找到</h1>
                <p>请求的文件不存在。</p>
            </body>
            </html>
        `);
    }
    
    send500(res) {
        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`
            <!DOCTYPE html>
            <html>
            <head>
                <title>500 - 服务器错误</title>
                <meta charset="utf-8">
            </head>
            <body>
                <h1>500 - 服务器内部错误</h1>
                <p>服务器处理请求时发生错误。</p>
            </body>
            </html>
        `);
    }
    
    start() {
        const server = http.createServer((req, res) => {
            console.log(`${req.method} ${req.url}`);
            
            if (req.method === 'GET') {
                this.serveFile(req, res);
            } else {
                res.writeHead(405, { 'Content-Type': 'text/plain' });
                res.end('Method Not Allowed');
            }
        });
        
        server.listen(this.port, () => {
            console.log(`静态文件服务器启动:http://localhost:${this.port}`);
            console.log(`服务目录:${this.rootDir}`);
        });
        
        return server;
    }
}

// 启动服务器
const fileServer = new StaticFileServer('./public', 3000);
fileServer.start();

基础静态文件服务要点

  • 路径安全:防止路径遍历攻击,确保只能访问指定目录内的文件
  • MIME类型:根据文件扩展名设置正确的Content-Type头
  • 错误处理:优雅处理文件不存在、权限错误等情况
  • 目录浏览:提供友好的目录列表界面

HTTP缓存策略实现

完整的缓存机制实现

javascript
// 🚀 带缓存策略的静态文件服务器
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');

class CachedStaticServer {
    constructor(rootDir = './public', options = {}) {
        this.rootDir = path.resolve(rootDir);
        this.options = {
            maxAge: options.maxAge || 3600, // 默认1小时缓存
            enableETag: options.enableETag !== false,
            enableGzip: options.enableGzip !== false,
            enableBrotli: options.enableBrotli !== false,
            ...options
        };
    }

    async serveFile(req, res) {
        try {
            const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
            let pathname = this.sanitizePath(parsedUrl.pathname);
            const filePath = path.join(this.rootDir, pathname);

            if (!fs.existsSync(filePath)) {
                this.send404(res);
                return;
            }

            const stats = fs.statSync(filePath);

            if (stats.isDirectory()) {
                const indexFile = this.findIndexFile(filePath);
                if (indexFile) {
                    await this.sendFileWithCache(req, res, indexFile);
                } else {
                    this.sendDirectoryListing(res, filePath, pathname);
                }
            } else {
                await this.sendFileWithCache(req, res, filePath);
            }

        } catch (error) {
            console.error('文件服务错误:', error);
            this.send500(res);
        }
    }

    async sendFileWithCache(req, res, filePath) {
        try {
            const stats = fs.statSync(filePath);
            const mimeType = this.getMimeType(filePath);

            // 生成ETag
            const etag = this.generateETag(stats);

            // 设置缓存相关头
            this.setCacheHeaders(res, stats, etag);

            // 检查条件请求
            if (this.checkConditionalRequest(req, stats, etag)) {
                res.writeHead(304);
                res.end();
                console.log(`304 Not Modified: ${filePath}`);
                return;
            }

            // 设置内容类型
            res.setHeader('Content-Type', mimeType);

            // 检查是否支持压缩
            const acceptEncoding = req.headers['accept-encoding'] || '';
            const shouldCompress = this.shouldCompress(mimeType, stats.size);

            if (shouldCompress && acceptEncoding.includes('br') && this.options.enableBrotli) {
                await this.sendCompressedFile(res, filePath, 'br');
            } else if (shouldCompress && acceptEncoding.includes('gzip') && this.options.enableGzip) {
                await this.sendCompressedFile(res, filePath, 'gzip');
            } else {
                await this.sendPlainFile(res, filePath, stats);
            }

        } catch (error) {
            console.error('发送文件错误:', error);
            this.send500(res);
        }
    }

    setCacheHeaders(res, stats, etag) {
        // 设置缓存控制
        res.setHeader('Cache-Control', `public, max-age=${this.options.maxAge}`);

        // 设置ETag
        if (this.options.enableETag) {
            res.setHeader('ETag', etag);
        }

        // 设置Last-Modified
        res.setHeader('Last-Modified', stats.mtime.toUTCString());

        // 设置Expires
        const expires = new Date(Date.now() + this.options.maxAge * 1000);
        res.setHeader('Expires', expires.toUTCString());
    }

    generateETag(stats) {
        // 基于文件大小和修改时间生成ETag
        const hash = crypto.createHash('md5');
        hash.update(`${stats.size}-${stats.mtime.getTime()}`);
        return `"${hash.digest('hex')}"`;
    }

    checkConditionalRequest(req, stats, etag) {
        // 检查If-None-Match (ETag)
        const ifNoneMatch = req.headers['if-none-match'];
        if (ifNoneMatch && ifNoneMatch === etag) {
            return true;
        }

        // 检查If-Modified-Since
        const ifModifiedSince = req.headers['if-modified-since'];
        if (ifModifiedSince) {
            const modifiedSince = new Date(ifModifiedSince);
            const lastModified = new Date(stats.mtime);

            // 忽略毫秒差异
            lastModified.setMilliseconds(0);

            if (lastModified <= modifiedSince) {
                return true;
            }
        }

        return false;
    }

    shouldCompress(mimeType, fileSize) {
        // 只压缩文本类型文件且大小超过阈值
        const compressibleTypes = [
            'text/',
            'application/javascript',
            'application/json',
            'application/xml',
            'image/svg+xml'
        ];

        const isCompressible = compressibleTypes.some(type => mimeType.includes(type));
        const isSizeAppropriate = fileSize > 1024; // 大于1KB才压缩

        return isCompressible && isSizeAppropriate;
    }

    async sendCompressedFile(res, filePath, encoding) {
        try {
            const stats = fs.statSync(filePath);

            // 设置压缩相关头
            res.setHeader('Content-Encoding', encoding);
            res.setHeader('Vary', 'Accept-Encoding');

            // 创建压缩流
            let compressor;
            if (encoding === 'br') {
                compressor = zlib.createBrotliCompress();
            } else if (encoding === 'gzip') {
                compressor = zlib.createGzip();
            }

            // 创建文件读取流
            const fileStream = fs.createReadStream(filePath);

            res.writeHead(200);

            // 管道:文件 -> 压缩 -> 响应
            fileStream.pipe(compressor).pipe(res);

            console.log(`发送压缩文件: ${filePath} (${encoding})`);

        } catch (error) {
            console.error('压缩文件发送错误:', error);
            this.send500(res);
        }
    }

    async sendPlainFile(res, filePath, stats) {
        try {
            res.setHeader('Content-Length', stats.size);

            const fileStream = fs.createReadStream(filePath);

            fileStream.on('error', (error) => {
                console.error('文件读取错误:', error);
                this.send500(res);
            });

            res.writeHead(200);
            fileStream.pipe(res);

            console.log(`发送文件: ${filePath}`);

        } catch (error) {
            console.error('文件发送错误:', error);
            this.send500(res);
        }
    }

    // 范围请求支持(断点续传)
    handleRangeRequest(req, res, filePath, stats) {
        const range = req.headers.range;
        if (!range) {
            return false;
        }

        const parts = range.replace(/bytes=/, "").split("-");
        const start = parseInt(parts[0], 10);
        const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;

        if (start >= stats.size || end >= stats.size) {
            res.writeHead(416, {
                'Content-Range': `bytes */${stats.size}`
            });
            res.end();
            return true;
        }

        const chunksize = (end - start) + 1;
        const fileStream = fs.createReadStream(filePath, { start, end });

        res.writeHead(206, {
            'Content-Range': `bytes ${start}-${end}/${stats.size}`,
            'Accept-Ranges': 'bytes',
            'Content-Length': chunksize,
            'Content-Type': this.getMimeType(filePath)
        });

        fileStream.pipe(res);
        console.log(`发送范围请求: ${filePath} (${start}-${end})`);

        return true;
    }

    // 其他方法保持不变...
    sanitizePath(pathname) {
        pathname = decodeURIComponent(pathname);
        pathname = pathname.split('?')[0];
        pathname = path.normalize(pathname);
        if (!pathname.startsWith('/')) pathname = '/' + pathname;
        if (pathname.includes('..')) pathname = '/';
        return pathname;
    }

    findIndexFile(dirPath) {
        const indexFiles = ['index.html', 'index.htm', 'default.html'];
        for (const indexFile of indexFiles) {
            const indexPath = path.join(dirPath, indexFile);
            if (fs.existsSync(indexPath)) return indexPath;
        }
        return null;
    }

    getMimeType(filePath) {
        const ext = path.extname(filePath).toLowerCase();
        const mimeTypes = {
            '.html': 'text/html; charset=utf-8',
            '.css': 'text/css; charset=utf-8',
            '.js': 'application/javascript; charset=utf-8',
            '.json': 'application/json; charset=utf-8',
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.svg': 'image/svg+xml',
            '.ico': 'image/x-icon',
            '.txt': 'text/plain; charset=utf-8',
            '.pdf': 'application/pdf',
            '.mp4': 'video/mp4',
            '.mp3': 'audio/mpeg',
            '.woff': 'font/woff',
            '.woff2': 'font/woff2'
        };
        return mimeTypes[ext] || 'application/octet-stream';
    }

    send404(res) {
        res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>404 - 文件未找到</h1>');
    }

    send500(res) {
        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>500 - 服务器内部错误</h1>');
    }

    sendDirectoryListing(res, dirPath, urlPath) {
        // 简化的目录列表实现
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`<h1>目录列表: ${urlPath}</h1><p>目录浏览功能</p>`);
    }

    start(port = 3000) {
        const server = http.createServer((req, res) => {
            console.log(`${req.method} ${req.url}`);

            if (req.method === 'GET' || req.method === 'HEAD') {
                this.serveFile(req, res);
            } else {
                res.writeHead(405, { 'Content-Type': 'text/plain' });
                res.end('Method Not Allowed');
            }
        });

        server.listen(port, () => {
            console.log(`缓存静态文件服务器启动:http://localhost:${port}`);
            console.log(`服务目录:${this.rootDir}`);
            console.log('缓存配置:', this.options);
        });

        return server;
    }
}

// 启动带缓存的服务器
const cachedServer = new CachedStaticServer('./public', {
    maxAge: 3600,        // 1小时缓存
    enableETag: true,    // 启用ETag
    enableGzip: true,    // 启用Gzip压缩
    enableBrotli: true   // 启用Brotli压缩
});

cachedServer.start(3000);

缓存策略要点

  • 🎯 ETag支持:基于文件内容生成唯一标识,支持条件请求
  • 🎯 Last-Modified:基于文件修改时间的缓存验证
  • 🎯 Cache-Control:设置缓存过期时间和缓存策略
  • 🎯 压缩支持:自动压缩文本类型文件,减少传输大小
  • 🎯 304响应:对未修改的文件返回304状态码,节省带宽

安全考虑和防护措施

安全的静态文件服务器实现

javascript
// 🔒 安全的静态文件服务器
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

class SecureStaticServer {
    constructor(rootDir = './public', options = {}) {
        this.rootDir = path.resolve(rootDir);
        this.options = {
            allowDotFiles: options.allowDotFiles || false,
            maxFileSize: options.maxFileSize || 100 * 1024 * 1024, // 100MB
            allowedExtensions: options.allowedExtensions || null,
            blockedExtensions: options.blockedExtensions || ['.exe', '.bat', '.cmd', '.sh'],
            enableDirectoryListing: options.enableDirectoryListing || false,
            rateLimitWindow: options.rateLimitWindow || 60000, // 1分钟
            rateLimitMax: options.rateLimitMax || 100, // 每分钟最多100请求
            ...options
        };

        this.rateLimitMap = new Map();
        this.setupCleanupInterval();
    }

    setupCleanupInterval() {
        // 定期清理过期的限流记录
        setInterval(() => {
            const now = Date.now();
            for (const [ip, data] of this.rateLimitMap.entries()) {
                if (now - data.windowStart > this.options.rateLimitWindow) {
                    this.rateLimitMap.delete(ip);
                }
            }
        }, this.options.rateLimitWindow);
    }

    checkRateLimit(req) {
        const clientIP = this.getClientIP(req);
        const now = Date.now();

        if (!this.rateLimitMap.has(clientIP)) {
            this.rateLimitMap.set(clientIP, {
                count: 1,
                windowStart: now
            });
            return true;
        }

        const data = this.rateLimitMap.get(clientIP);

        // 检查是否在同一时间窗口内
        if (now - data.windowStart < this.options.rateLimitWindow) {
            data.count++;
            if (data.count > this.options.rateLimitMax) {
                return false; // 超过限制
            }
        } else {
            // 新的时间窗口
            data.count = 1;
            data.windowStart = now;
        }

        return true;
    }

    getClientIP(req) {
        return req.headers['x-forwarded-for'] ||
               req.headers['x-real-ip'] ||
               req.connection.remoteAddress ||
               req.socket.remoteAddress ||
               '127.0.0.1';
    }

    validatePath(pathname) {
        // 1. 防止路径遍历
        const normalizedPath = path.normalize(pathname);
        if (normalizedPath.includes('..')) {
            throw new Error('Path traversal detected');
        }

        // 2. 检查隐藏文件
        if (!this.options.allowDotFiles && path.basename(normalizedPath).startsWith('.')) {
            throw new Error('Access to dot files denied');
        }

        // 3. 检查文件扩展名
        const ext = path.extname(normalizedPath).toLowerCase();

        if (this.options.blockedExtensions.includes(ext)) {
            throw new Error(`File extension ${ext} is blocked`);
        }

        if (this.options.allowedExtensions && !this.options.allowedExtensions.includes(ext)) {
            throw new Error(`File extension ${ext} is not allowed`);
        }

        return normalizedPath;
    }

    validateFile(filePath, stats) {
        // 检查文件大小
        if (stats.size > this.options.maxFileSize) {
            throw new Error('File too large');
        }

        // 检查文件权限
        try {
            fs.accessSync(filePath, fs.constants.R_OK);
        } catch (error) {
            throw new Error('File access denied');
        }

        return true;
    }

    setSecurityHeaders(res) {
        // 设置安全相关的HTTP头
        res.setHeader('X-Content-Type-Options', 'nosniff');
        res.setHeader('X-Frame-Options', 'DENY');
        res.setHeader('X-XSS-Protection', '1; mode=block');
        res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
        res.setHeader('Content-Security-Policy', "default-src 'self'");

        // 移除可能泄露服务器信息的头
        res.removeHeader('X-Powered-By');
    }

    async serveFile(req, res) {
        try {
            // 1. 检查限流
            if (!this.checkRateLimit(req)) {
                res.writeHead(429, { 'Content-Type': 'text/plain' });
                res.end('Too Many Requests');
                return;
            }

            // 2. 设置安全头
            this.setSecurityHeaders(res);

            // 3. 解析和验证路径
            const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
            const pathname = this.validatePath(decodeURIComponent(parsedUrl.pathname));
            const filePath = path.join(this.rootDir, pathname);

            // 4. 检查文件是否存在
            if (!fs.existsSync(filePath)) {
                this.send404(res);
                return;
            }

            const stats = fs.statSync(filePath);

            // 5. 处理目录请求
            if (stats.isDirectory()) {
                if (!this.options.enableDirectoryListing) {
                    this.send403(res);
                    return;
                }

                const indexFile = this.findIndexFile(filePath);
                if (indexFile) {
                    const indexStats = fs.statSync(indexFile);
                    this.validateFile(indexFile, indexStats);
                    await this.sendFile(res, indexFile, indexStats);
                } else {
                    this.sendDirectoryListing(res, filePath, pathname);
                }
            } else {
                // 6. 验证文件
                this.validateFile(filePath, stats);
                await this.sendFile(res, filePath, stats);
            }

        } catch (error) {
            console.error('安全验证失败:', error.message);

            if (error.message.includes('traversal') ||
                error.message.includes('denied') ||
                error.message.includes('blocked')) {
                this.send403(res);
            } else if (error.message.includes('too large')) {
                this.send413(res);
            } else {
                this.send500(res);
            }
        }
    }

    async sendFile(res, filePath, stats) {
        try {
            const mimeType = this.getMimeType(filePath);

            // 设置响应头
            res.setHeader('Content-Type', mimeType);
            res.setHeader('Content-Length', stats.size);
            res.setHeader('Last-Modified', stats.mtime.toUTCString());

            // 对于可执行文件类型,强制下载
            const ext = path.extname(filePath).toLowerCase();
            const executableTypes = ['.exe', '.bat', '.cmd', '.sh', '.ps1'];
            if (executableTypes.includes(ext)) {
                res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
            }

            // 创建文件流
            const fileStream = fs.createReadStream(filePath);

            fileStream.on('error', (error) => {
                console.error('文件读取错误:', error);
                this.send500(res);
            });

            res.writeHead(200);
            fileStream.pipe(res);

            console.log(`安全发送文件: ${filePath}`);

        } catch (error) {
            console.error('文件发送错误:', error);
            this.send500(res);
        }
    }

    findIndexFile(dirPath) {
        const indexFiles = ['index.html', 'index.htm', 'default.html'];
        for (const indexFile of indexFiles) {
            const indexPath = path.join(dirPath, indexFile);
            if (fs.existsSync(indexPath)) {
                return indexPath;
            }
        }
        return null;
    }

    getMimeType(filePath) {
        const ext = path.extname(filePath).toLowerCase();
        const mimeTypes = {
            '.html': 'text/html; charset=utf-8',
            '.css': 'text/css; charset=utf-8',
            '.js': 'application/javascript; charset=utf-8',
            '.json': 'application/json; charset=utf-8',
            '.png': 'image/png',
            '.jpg': 'image/jpeg',
            '.jpeg': 'image/jpeg',
            '.gif': 'image/gif',
            '.svg': 'image/svg+xml',
            '.ico': 'image/x-icon',
            '.txt': 'text/plain; charset=utf-8',
            '.pdf': 'application/pdf',
            '.mp4': 'video/mp4',
            '.mp3': 'audio/mpeg'
        };
        return mimeTypes[ext] || 'application/octet-stream';
    }

    sendDirectoryListing(res, dirPath, urlPath) {
        // 简化的安全目录列表
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end(`
            <h1>目录列表: ${urlPath}</h1>
            <p>安全的目录浏览功能</p>
            <p><em>注意:某些文件类型可能被隐藏以确保安全</em></p>
        `);
    }

    send403(res) {
        res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>403 - 访问被拒绝</h1><p>您没有权限访问此资源。</p>');
    }

    send404(res) {
        res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>404 - 文件未找到</h1>');
    }

    send413(res) {
        res.writeHead(413, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>413 - 文件过大</h1>');
    }

    send500(res) {
        res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
        res.end('<h1>500 - 服务器内部错误</h1>');
    }

    start(port = 3000) {
        const server = http.createServer((req, res) => {
            const clientIP = this.getClientIP(req);
            console.log(`${req.method} ${req.url} - ${clientIP}`);

            if (req.method === 'GET' || req.method === 'HEAD') {
                this.serveFile(req, res);
            } else {
                res.writeHead(405, { 'Content-Type': 'text/plain' });
                res.end('Method Not Allowed');
            }
        });

        server.listen(port, () => {
            console.log(`安全静态文件服务器启动:http://localhost:${port}`);
            console.log(`服务目录:${this.rootDir}`);
            console.log('安全配置:', {
                allowDotFiles: this.options.allowDotFiles,
                maxFileSize: `${this.options.maxFileSize / 1024 / 1024}MB`,
                rateLimitMax: this.options.rateLimitMax,
                enableDirectoryListing: this.options.enableDirectoryListing
            });
        });

        return server;
    }
}

// 启动安全的静态文件服务器
const secureServer = new SecureStaticServer('./public', {
    allowDotFiles: false,
    maxFileSize: 50 * 1024 * 1024, // 50MB
    blockedExtensions: ['.exe', '.bat', '.cmd', '.sh', '.ps1'],
    enableDirectoryListing: false,
    rateLimitMax: 100
});

secureServer.start(3000);

安全防护要点

  • 🎯 路径遍历防护:严格验证文件路径,防止访问系统文件
  • 🎯 文件类型限制:阻止危险文件类型的访问和执行
  • 🎯 访问控制:限制隐藏文件访问,控制目录浏览权限
  • 🎯 限流保护:实现IP级别的请求频率限制
  • 🎯 安全头设置:添加各种安全相关的HTTP响应头

📚 静态文件服务学习总结与下一步规划

✅ 本节核心收获回顾

通过本节Node.js静态文件服务教程的学习,你已经掌握:

  1. 静态文件服务基础:理解了静态文件服务的概念和在Web开发中的重要作用
  2. 文件系统操作:掌握了Node.js文件系统模块的使用和安全的文件读取方法
  3. MIME类型处理:学会了根据文件扩展名正确设置Content-Type响应头
  4. HTTP缓存策略:实现了完整的缓存机制,包括ETag、Last-Modified等
  5. 性能优化技巧:掌握了文件压缩、范围请求等性能优化方法
  6. 安全防护措施:了解了路径遍历、文件类型限制等安全考虑

🎯 静态文件服务下一步

  1. 学习专业工具:了解Nginx、Apache等专业Web服务器的配置
  2. CDN集成:学习内容分发网络的使用和配置
  3. 监控和日志:实现访问日志记录和性能监控
  4. 集群部署:学习多实例部署和负载均衡

🔗 相关学习资源

💪 实践练习建议

  1. 构建图片服务器:支持图片上传、缩放、格式转换
  2. 实现文件管理系统:支持文件上传、下载、预览功能
  3. 开发静态博客系统:结合Markdown解析和静态文件服务
  4. 性能测试:使用工具测试不同缓存策略的性能差异

🔍 常见问题FAQ

Q1: 静态文件服务器和动态Web服务器有什么区别?

A: 静态文件服务器只提供预先存在的文件,不进行服务端处理;动态Web服务器可以根据请求生成内容。静态服务器性能更高,但功能有限。实际应用中通常两者结合使用。

Q2: 如何选择合适的缓存策略?

A: 根据文件类型选择:1)HTML文件使用较短缓存时间;2)CSS/JS文件使用版本号+长期缓存;3)图片等资源使用中等缓存时间;4)频繁更新的文件禁用缓存或使用ETag。

Q3: 生产环境中应该使用Node.js作为静态文件服务器吗?

A: 对于高流量网站,建议使用Nginx等专业Web服务器处理静态文件,Node.js专注于动态内容。但对于中小型应用,Node.js的静态文件服务完全可以满足需求。

Q4: 如何处理大文件的下载?

A: 使用流式传输和范围请求:1)支持HTTP Range头;2)实现断点续传;3)使用适当的缓冲区大小;4)设置合理的超时时间;5)考虑使用专门的文件下载服务。

Q5: 如何监控静态文件服务的性能?

A: 关键指标包括:1)响应时间;2)缓存命中率;3)带宽使用量;4)并发连接数;5)错误率。可以使用APM工具或自建监控系统收集这些指标。


"静态文件服务是Web应用的基础设施,掌握了高效、安全的静态文件服务技术,你就具备了构建高性能Web应用的重要能力。继续深入学习,向Web架构专家的目标迈进!"