Node.js搭建文件服务器,实现文件上传下载编辑播放的功能

5,646 阅读7分钟

设计初衷:

上机实验课的时候不想带电脑去机房, 但是在机房又可能需要使用到自己电脑里的文件, 再加上机房电脑没有安装网盘等应用, 每次上机都要下载登录比较麻烦, 所以用Node.js搭建了这个文件服务器。

技术栈:

JavaScript + Node.js + formidable

该文件服务器有以下好处:

1.方便在机房、网吧等临时使用的电脑上快速下载需要的文件, 只要打开浏览器就可以下载事先上传好的文件

2.方便手机和电脑在不安装其他应用的情况下传输文件, 因为手机和电脑都内置了浏览器

3.方便和别人共享文件, 不用担心限速

4.能在大部分情况下取代U盘, 带着U盘怕掉, 用的时候还要占用一个USB接口

该文件服务器的主要功能:

1. 上传文件:

多文件上传
文件上传大小限制
控制是否覆盖同名文件
上传等待提示

2. 显示文件:

显示文件总数

显示文件名、文件大小、修改时间

可以按照文件名、文件大小、修改时间对文件排序显示

3. 下载文件

4. 删除文件

5. 身份验证

6. 权限控制:

身份验证失败或没有验证只能得到public目录下的文件, 即使有下载地址也无法下载文件

7. 视频、音乐在线播放

8. 图片在线查看、文本在线编辑

9. 登录信息(包含IP地址)保存到日志文件

重要提醒, 本项目已更新

本次更新新增功能:
文件夹上传/文件夹下载/查看文件夹内容
上传文件时以百分比显示文件的上传进度
移动文件/新建文件/新建文件夹
更友好的弹窗提示(layer.js)
保存和查看登录记录
可以开放一个公共文件夹用来分享文件给任意人查看下载
显示根目录到当前文件夹的路径信息, 点击路径中的文件夹可以跳转到该文件夹

(更新后下载地址不变)

效果图

身份验证页面:

首页:

按文件大小从大到小排序显示:

点击“v.mp4”观看视频:

点击“游鸿明 - 21个人.mp3”播放音乐:

点击“文本文件.txt”在线编辑文件:

源代码下载地址:

github.com/1061186575/…

运行方法

1.安装Node.js

2.安装项目依赖(formidable)
进入到项目的根目录,输入:

npm install

3.运行项目
进入到项目的根目录,输入:

node app.js

项目目录结构:

文件服务器
|-- config
    |-- config.js
|-- control
    |-- control.js
|-- log
    |-- login_info.log
|-- node_modules
|-- public
    |-- css
        |-- index.css
    |-- js
        |-- jq.js
    |-- editText.html
    |-- playMusic.html
    |-- playVideo.html
    |-- verify.html
|-- uploads
|-- app.js
|-- index.html
|-- package.json

代码文件

首先介绍config.js文件,项目的配置文件

const path = require('path');

// 登录系统的账号密码
const systemUser = "zp"
const systemPassword = "xxx";

// 运行端口
const port = 3000

// 保存上传文件的目录
const uploadDir = path.join(process.cwd(), 'uploads/')

// 保存登录信息的日志文件
const login_info_path = path.join(process.cwd(), "log", 'login_info.log')

module.exports = {
    port,
    systemUser,
    systemPassword,
    uploadDir,
    login_info_path
}

control.js:负责具体功能的实现

该文件定义并导出了6个处理函数,函数体代码比较多,在这里先省略

const fs = require('fs');
const formidable = require('formidable')

const {
    systemUser,
    systemPassword,
    uploadDir,
    login_info_path
} = require('../config/config.js')


// 定义6个处理函数,功能分别如下:

// 验证账号密码, 验证成功则设置cookie
function identityVerify(req, res) {}
// cookie验证
function cookieVerify(cookies) {}
// 读取uploadDir目录下的文件信息并返回
function getAllFileInfo(req, res) {}
// 上传文件
function uploadFile(req, res) {}
// 删除文件
function deleteFile(req, res) {}
// 修改文本文件
function modifyTextFile(req, res) {}

// 导出这6个函数
module.exports = {
    identityVerify,
    cookieVerify,
    getAllFileInfo,
    uploadFile,
    deleteFile,
    modifyTextFile
}

app.js: 项目的入口文件

创建服务并监听端口,收到请求后就调用control.js下对应的函数处理请求


const http = require('http');
const path = require('path');
const fs = require('fs');


const {
    port,
    uploadDir
} = require('./config/config.js')

const {
    identityVerify,
    cookieVerify,
    getAllFileInfo,
    uploadFile,
    deleteFile,
    modifyTextFile
} = require('./control/control');



// 如果uploadDir目录不存在就创建目录
if (!fs.existsSync(uploadDir)) {
    fs.mkdirSync(uploadDir)
}

// 发送页面
function sendPage(res, path, statusCode = 200) {
    res.writeHead(statusCode, { 'Content-Type': 'text/html;charset=UTF-8' });
    fs.createReadStream(path).pipe(res)
}

// 文件不存在返回404
function handle404(res, fileDir) {
    if (!fs.existsSync(fileDir)) {
        res.writeHead(404, { 'content-type': 'text/html;charset=UTF-8' });
        res.end("404, no such file or directory");
        console.log("no such file or directory: ", fileDir);
        return true; // 处理成功
    }
    return false
}

var server = http.createServer(function(req, res) {

    let url = decodeURI(req.url);
    console.log("url: ", url);

    let method = req.method.toLowerCase()

    let parameterPosition = url.indexOf('?')
    if (parameterPosition > -1) {
        url = url.slice(0, parameterPosition) // 去掉url中的参数部分
        console.log("去掉参数后的url: ", url);
    }

    // 访问public接口时发送public目录下的文件, 不需要任何验证
    if (/^\/public\//.test(url)) {
        let fileDir = '.' + url;
        if (!handle404(res, fileDir)) {
            fs.createReadStream(fileDir).pipe(res)
        }
        return;
    }

    // 身份验证的接口
    if (url === '/identityVerify' && method === 'post') {
        identityVerify(req, res)
        return;
    }

    // cookie验证, 如果验证不成功, 就只发送verify.html
    if (!cookieVerify(req.headers.cookie)) {
        sendPage(res, './public/verify.html', 400);
        return;
    }


    if (url === '/' && method === 'get') {

        sendPage(res, './index.html');

    } else if (url === '/getAllFileInfo' && method === 'get') {

        // 读取uploadDir目录下的文件信息并返回
        getAllFileInfo(req, res)

    } else if (url === '/uploadFile' && method === 'post') {

        // 上传文件
        uploadFile(req, res)

    } else if (/^\/deleteFile?/.test(url) && method === 'get') {

        // 删除文件
        deleteFile(req, res)

    } else if (/^\/modifyTextFile?/.test(url) && method === 'post') {

        // 修改文本文件
        modifyTextFile(req, res)

    } else {

        // 下载文件, 默认发送uploads目录下的文件
        let fileDir = path.join(uploadDir, url);
        if (!handle404(res, fileDir)) {
            console.log("下载文件: ", fileDir);
            fs.createReadStream(fileDir).pipe(res)
        }

    }




})

server.listen(port);
console.log('running port:', port)


// 异常处理
process.on("uncaughtException", function(err) {
    if (err.code == 'ENOENT') {
        console.log("no such file or directory: ", err.path);
    } else {
        console.log(err);
    }
})


process.on("SIGINT", function() {
    process.exit()
})
process.on("exit", function() {
    console.log("exit");
})

package.json文件

{
    "name": "zp_file_server",
    "version": "1.0.0",
    "devDependencies": {
        "formidable": "^1.2.1"
    }
}

login_info.log文件是自动生成的登录日志文件

登录信息会自动保存到这个文件,格式如下:

后端的Node.js文件介绍完了,接下来介绍前端的文件

index.html文件

html部分:


<form id="uploadFileForm" name="uploadFileForm">
    <h3>
        上传文件
        <label for="allowCoverage" style="font-size: 16px;margin-right: 15px;">
            <input type="checkbox" name="allowCoverage" value="true" id="allowCoverage">允许覆盖同名文件
        </label>
        <button type="button" class="btn btn-radius30" onclick="clearCookie()">退出</button>
    </h3>
    <div>
    </div>
    <input type="file" id="fileSelect" name="uploadFile" multiple>
    <button type="button" id="uploadFileBtn" style="color: blue;">上传文件</button>
    <div id="uploading" hidden style="margin-top: 10px;color: red;">文件上传中, 请稍等...</div>
</form>

<h3 style="margin-left: 15px;">文件列表(<span id="fileCount">0</span>个文件):</h3>
<table>
    <thead>
        <tr>
            <th onclick="sort('src')">文件名</th>
            <th onclick="sort('size')">文件大小</th>
            <th onclick="sort('mtimeMs')">修改时间</th>
            <th>操作</th>
        </tr>
    </thead>
    <tbody id='allFile'>

    </tbody>
</table>
    

js部分:

先发送ajax获取文件信息,得到文件信息的数据后传递给renderFileList函数

let fileCount = document.getElementById('fileCount');
let AllFileData = [];
$.ajax({
    url: '/getAllFileInfo',
    type: 'get',
    success(d) {
        d = JSON.parse(d);
        console.log("文件信息: ", d);
        AllFileData = d;
        // 显示文件总数
        fileCount.textContent = AllFileData.length;
        renderFileList(d)
    },
    error(err) {
        alert(err);
        console.log("err: ", err);
    }
})

renderFileList函数的具体实现:

// 将数据拼接成HTML然后添加到页面上
function renderFileList(dataArray) {
    var fileListHTML = '';
    for (let item of dataArray) {
        // href='${item.src}'
        fileListHTML += `
            <tr>
                <td>
                    <a
                        title='${item.src}' 
                        onclick="processingResource('${item.src}')">
                            ${item.src}
                        </a>
                    </td>
                <td>${fileSizeFormat(item.size)}</td>
                <td>${modifyTimeFormat(item.mtimeMs)}</td>
                <td>
                    <button data-fileName='${item.src}' class="btn-download">下载</button>
                    <button data-fileName='${item.src}' class="btn-delete">删除</button>
                </td>
            </tr>`
    }
    document.getElementById('allFile').innerHTML = fileListHTML;
}

renderFileList函数在拼接HTML的过程中还会调用fileSizeFormat函数和modifyTimeFormat函数,这两个函数分别对文件大小和文件修改时间进行格式化处理:

// 文件大小的格式, 为了程序可读性就直接写1024而不是1024的乘除结果(例如1024*1024可以写成1048576,运行会更快一点)
function fileSizeFormat(fileSize) {
    fileSize = Number(fileSize);
    if (fileSize < 1024) {
        return fileSize.toFixed(2) + 'B'
    } else if (fileSize < 1024 * 1024) {
        return (fileSize / 1024).toFixed(2) + 'KB'
    } else if (fileSize < 1024 * 1024 * 1024) {
        return (fileSize / 1024 / 1024).toFixed(2) + 'MB'
    } else {
        return (fileSize / 1024 / 1024 / 1024).toFixed(2) + 'GB'
    }
}

// 修改时间显示的格式
function modifyTimeFormat(mtimeMs) {
    return new Date(mtimeMs).toLocaleString()
}
        

此时就可以得到文件列表信息了:

当点击文件名时会调用processingResource函数,processingResource函数会根据文件后缀判断文件类型, mp3、mp4类型的文件会打开播放页面, 常见的文本文件会打开编辑页面:

function processingResource(src) {
    console.log(src);
    let hasSuffix = src.lastIndexOf('.');
    let suffix; // 文件后缀
    if (hasSuffix > -1) {
        suffix = src.slice(hasSuffix + 1).toLocaleLowerCase();
        console.log(suffix);
    }
    switch (suffix) {
        case "mp3":
            window.open("./public/playMusic.html?" + src)
            break;
        case "mp4":
            window.open("./public/playVideo.html?" + src)
            break;
        case "txt":
        case "css":
        case "js":
        case "java":
            window.open("./public/editText.html?" + src)
            break;
        default:
            window.open(src)
            break;
    }

}

点击退出时清除cookie:


function clearCookie() {
    let cookie = document.cookie;
    let cookieArray = [];
    if (cookie) {
        cookieArray = cookie.split(';')
    }
    console.log(cookie);
    console.log(cookieArray);

    cookieArray.map(function(item) {
        let itemCookie = item.trim().split('=');
        const cookie_key = itemCookie[0];
        const cookie_value = itemCookie[1];
        document.cookie = cookie_key + '=0;expires=' + new Date(0).toUTCString()
    })
    location.reload();
}

文件名、文件大小、修改时间的排序处理:

// 标记当前是从小到大排序还是从大到小排序, 文件名、大小、修改时间都使用sort_flag标记, 懒得单独记录它们的状态了
let sort_flag = false;

// 排序
function sort(type) {
    let data = JSON.parse(JSON.stringify(AllFileData)) // 深拷贝数组

    sort_flag = !sort_flag;

    // 选择排序
    for (let i = 0; i < data.length - 1; i++) {
        for (let j = i + 1; j < data.length; j++) {

            if (sort_flag) {
                if (data[j][type] > data[i][type]) {
                    swap(data, i, j)
                }
            } else {
                if (data[j][type] < data[i][type]) {
                    swap(data, i, j)
                }
            }
        }

        // 更新文件列表
        renderFileList(data)
    }
}

// 两数交换
function swap(data, i, j) {
    let temp;

    temp = data[i].src;
    data[i].src = data[j].src;
    data[j].src = temp;

    temp = data[i].size;
    data[i].size = data[j].size;
    data[j].size = temp;

    temp = data[i].mtimeMs;
    data[i].mtimeMs = data[j].mtimeMs;
    data[j].mtimeMs = temp;
}

文件删除和下载操作

// 给所有按钮的父元素添加点击事件
allFile.onclick = function(e) {

    if (e.target.tagName !== 'BUTTON') return;

    var fileName = e.target.dataset.filename;
    var className = e.target.className;

    // 删除
    if (className.includes('btn-delete')) {
        if (!confirm("确认删除?")) return;

        $.ajax({
            url: '/deleteFile?' + fileName,
            type: 'get',
            success(d) {
                console.log("d: ", d);
                if (d) {
                    alert(d);
                } else {
                    e.target.parentElement.parentElement.remove();
                    fileCount.textContent = ~~fileCount.textContent - 1; // 页面显示的文件总数减一
                }
            },
            error(err) {
                console.log("err: ", err);
            }
        })

    } else if (className.includes('btn-download')) { // 下载
        let a_tag = document.createElement('a')
        a_tag.href = fileName;
        a_tag.download = fileName;
        a_tag.click()
        a_tag = null
    }

}

文件上传

// 前端限制上传的文件最大为1GB, 程序员可以在浏览器控制台修改maxFileSize的值用来上传更大的文件, 后端限制最大10GB
let maxFileSize = 1 * 1024 * 1024 * 1024;

uploadFileBtn.onclick = function() {

    if (!fileSelect.files.length) {
        alert('请先选择文件')
        return;
    }

    let allUploadFileSize = 0;
    Array.from(fileSelect.files).forEach(file => {
        allUploadFileSize += file.size;
    })

    if (allUploadFileSize > maxFileSize) {
        alert('文件总大小大于1GB, 无法上传')
        return;
    }

    if (allUploadFileSize > 10 * 1024 * 1024 * 1024) { // 后端限制文件最大为10GB
        alert('文件总大小大于10GB, 前端改代码也无法上传...')
        return;
    }

    // 防止快速连续多次点击导致重复上传
    uploadFileBtn.disabled = true;
    uploading.hidden = false;

    var fd = new FormData(document.forms['uploadFileForm']);
    $.ajax({
        url: '/uploadFile',
        type: 'post',
        data: fd,
        contentType: false, // 取消自动的设置请求头
        processData: false, //取消自动格式化数据
        enctype: 'multipart/form-data',
        success(d) {
            if (d) {
                alert(d)
            } else {
                location.reload();
            }
            console.log("d: ", d);
        },
        error(err) {
            console.log("err: ", err);
            alert(err.responseText)
        },
        complete() {
            uploadFileBtn.disabled = false;
            uploading.hidden = true;
        }
    })
}

index.css文件

table td {
    padding: 0 15px;
    border: 1px solid #03a9f4;
}

table thead th {
    user-select: none;
    background: #eee;
    cursor: pointer;
}

#allFile a {
    text-decoration: none;
    color: #03A9F4;
    display: inline-block;
    max-width: 300px;
    line-height: 35px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    cursor: pointer;
}

#allFile a:hover {
    text-decoration: underline;
    color: #ff5722;
}

#allFile button {
    display: inline-block;
    width: 80px;
    height: 30px;
    line-height: 30px;
    padding: 0 18px;
    background-color: #009688;
    color: #fff;
    white-space: nowrap;
    text-align: center;
    font-size: 14px;
    border: none;
    cursor: pointer;
    outline: none;
    border-radius: 4px;
    text-decoration: none;
}

#allFile button:hover {
    opacity: .8;
    filter: alpha(opacity=80);
    color: #fff
}

#allFile button:active {
    opacity: 1;
    filter: alpha(opacity=100)
}

.btn-delete {
    background-color: #cc614b!important;
}

.btn-radius30 {
    border-radius: 30px!important;
}

.btn {
    display: inline-block;
    width: 80px;
    height: 30px;
    line-height: 30px;
    padding: 0 18px;
    background-color: #03A9F4;
    color: #fff;
    white-space: nowrap;
    text-align: center;
    font-size: 14px;
    border: none;
    cursor: pointer;
    outline: none;
    border-radius: 4px;
    text-decoration: none;
    box-shadow: 2px 2px 2px #FF9800;
}

#uploadFileForm {
    width: 400px;
    height: 110px;
    border: 1px dashed red;
    padding: 20px;
    margin: 10px auto;
}

视频在线播放页面playVideo.html

<style>
    .font-art {
        text-shadow: 0 -1px 5px rgba(0, 0, 0, .4);
        color: #60497C;
        /* font-size: 2em; */
        text-align: center;
        font-weight: bold;
        word-spacing: 20px;
        margin-top: 10px;
        cursor: pointer;
    }
</style>

<h3 class="font-art" onclick="location.href='/'">视频无法播放请检查身份验证信息是否过期</h3>

<div style='width:90%;margin:auto;'>
    <h3 id="fileName"></h3>
    <video controls width='100%' autoplay id="playVideo"></video>
</div>

<script>
    let src = location.search.slice(1);
    playVideo.src = '/' + src;
    fileName.textContent = decodeURI(src);
</script>

音乐在线播放页面和视频在线播放页面很像,playMusic.html:

<h3 class="font-art" onclick="location.href='/'">音乐无法播放请检查身份验证信息是否过期</h3>

<div style='width:90%;margin:auto;'>
    <h3 id="fileName"></h3>
    <audio controls width='100%' autoplay id="playMusic"></video>
</div>

<script>
    let src = location.search.slice(1);
    playMusic.src = '/' + src;
    fileName.textContent = decodeURI(src);
</script>

文本在线编辑页面editText.html

先发起请求获取到某文本文件的内容,然后添加到textarea文本域里,用户修改textarea文本域里的内容,然后点击保存按钮,将新的文本信息发给后端,后端将新的文本信息写入到文本文件里,就实现了编辑功能。

editText.html主要代码:

<div style='width:90%;margin:auto;text-align: center;'>
    <h3 id="showFileName"></h3>
    <textarea name="" id="textareaEle" cols="30" rows="10"></textarea>
    <div>
        <button id="modifyBtn" class="btn">保存</button>
    </div>
</div>

<script>
    let src = location.search.slice(1);
    var fileName = decodeURI(src);
    showFileName.textContent = fileName;

    $.ajax({
        url: '/' + fileName,
        type: 'get',
        success(d) {
            console.log("d: ", d);
            textareaEle.value = d
        },
        error(err) {
            console.log("err: ", err);
        }
    })

    modifyBtn.onclick = function() {
        $.ajax({
            url: '/modifyTextFile?' + fileName,
            type: 'post',
            data: textareaEle.value,
            success(d) {
                console.log("d: ", d);
                d = JSON.parse(d)
                if (d.code === 0) {

                } else {

                }
                alert(d.msg);
            },
            error(err) {
                console.log("err: ", err);
            }
        })
    }
</script>

文本编辑页面的保存功能对应的后端代码:

// 根据文件名和数据修改(覆盖)文本文件
function modifyTextFile(req, res) {
    let url = decodeURI(req.url);
    let fileName = url.slice(url.indexOf('?') + 1);
    console.log("修改(覆盖)文本文件: ", fileName)

    let WriteStream = fs.createWriteStream(uploadDir + fileName)

    WriteStream.on('error', function(err) {
        res.end(JSON.stringify({ code: 1, msg: JSON.stringify(err) }))
    })

    WriteStream.on('finish', function() {
        res.end(JSON.stringify({ code: 0, msg: "保存成功" }))
    })

    req.on('data', function(data) {
        WriteStream.write(data)
    })

    req.on('end', function() {
        WriteStream.end()
        WriteStream.close()
    })
}

control.js文件的所有代码:


const fs = require('fs');
const formidable = require('formidable')

const {
    systemUser,
    systemPassword,
    uploadDir,
    login_info_path
} = require('../config/config.js')



const log = console.log;
let login_info_writeStream = fs.createWriteStream(login_info_path, { flags: 'a' })


//通过req的hearers来获取客户端ip
function getIp(req) {
    let ip = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddres || req.socket.remoteAddress || '';
    return ip;
}


// 验证账号密码, 验证成功则设置cookie, 验证结果写入到login_info.log日志文件里
function identityVerify(req, res) {

    let clientIp = getIp(req);
    console.log('客户端ip: ', clientIp);

    let verify_str = ''
    req.on('data', function(verify_data) {
        verify_str += verify_data;
    })
    req.on('end', function() {
        let verify_obj = {};
        try {
            verify_obj = JSON.parse(verify_str)
        } catch (e) {
            console.log(e);
        }
        log("verify_obj", verify_obj)

        res.writeHead(200, {
            'Content-Type': 'text/plain;charset=UTF-8'
        });

        // 保存登录信息日志
        login_info_writeStream.write("Time: " + new Date().toLocaleString() + '\n')
        login_info_writeStream.write("IP地址: " + clientIp + '\n')

        if (verify_obj.user === systemUser && verify_obj.password === systemPassword) {
            // 验证成功
            log("验证成功")

            login_info_writeStream.write("User: " + verify_obj.user + '\n验证成功\n\n')

            // 设置cookie, 过期时间2小时
            res.writeHead(200, {
                'Set-Cookie': verify_obj.user + "=" + verify_obj.password + ";path=/;expires=" + new Date(Date.now() + 1000 * 60 * 60 * 2).toGMTString(),
            });
            res.end(JSON.stringify({ code: 0, msg: "验证成功" }));

        } else {
            // 验证失败
            login_info_writeStream.write("User: " + verify_obj.user + "\t\t\t\tPassword: " + verify_obj.password + '\n验证失败\n\n')
            res.end(JSON.stringify({ code: 1, msg: "验证失败" }));
        }

    })
}




// 把cookie拆分成数组
function cookiesSplitArray(cookies) {
    // let cookies = req.headers.cookie;
    let cookieArray = [];
    if (cookies) {
        cookieArray = cookies.split(';')
    }
    return cookieArray;
}

// 把单个cookie的键值拆开
function cookieSplitKeyValue(cookie) {
    if (!cookie) return {};
    let KeyValue = cookie.trim().split('=');
    const cookie_key = KeyValue[0];
    const cookie_value = KeyValue[1];
    return { cookie_key, cookie_value }
}

// cookie验证
// 如果cookie中有一对键值等于系统登录的账号密码, 就认为验证成功(验证失败最多只能获得public目录下的文件)
function cookieVerify(cookies) {
    const cookieArray = cookiesSplitArray(cookies)

    // 新增的cookie一般在最后, 因此数组从后往前遍历
    for (let index = cookieArray.length; index >= 0; index--) {
        const item = cookieArray[index];
        const { cookie_key, cookie_value } = cookieSplitKeyValue(item);
        
        if (cookie_key === systemUser && cookie_value === systemPassword) {
            return true;
        }
    }

    return false;
}



// 读取uploadDir目录下的文件信息并返回
function getAllFileInfo(req, res) {
    fs.readdir(uploadDir, (err, data) => {
        // console.log(data);
        let resultArray = [];
        for (let d of data) {
            let statSyncRes = fs.statSync(uploadDir + d);
            // console.log("statSyncRes", statSyncRes)
            resultArray.push({
                src: d,
                size: statSyncRes.size,
                //mtimeMs: statSyncRes.mtimeMs,  // 我发现有些电脑上的文件没有mtimeMs属性, 所以将mtime转成时间戳发过去
                mtimeMs: new Date(statSyncRes.mtime).getTime()
            })
        }
        // console.log(resultArray);
        res.end(JSON.stringify(resultArray))
    })
}


// 上传文件
function uploadFile(req, res) {
    console.log("上传文件");

    var form = new formidable.IncomingForm();
    form.uploadDir = uploadDir; // 保存上传文件的目录
    form.multiples = true; // 设置为多文件上传
    form.keepExtensions = true; // 保持原有扩展名
    form.maxFileSize = 10 * 1024 * 1024 * 1024; // 文件最大为10GB

    // 文件大小超过限制会触发error事件
    form.on("error", function(e) {
        console.log("文件大小超过限制, error: ", e);
        res.writeHead(400, { 'content-type': 'text/html;charset=UTF-8' });
        res.end("文件大小超过10GB, 无法上传, 你难道不相信?")
    })


    form.parse(req, function(err, fields, files) {

        if (err) {
            console.log("err: ", err);
            res.writeHead(500, { 'content-type': 'text/html;charset=UTF-8' });
            res.end('上传文件失败: ' + JSON.stringify(err));
            return;
        }

        // console.log(files);
        // console.log(files.uploadFile);

        if (!files.uploadFile) {
            res.end('上传文件的name需要为uploadFile');
            return
        };


        // 单文件上传时files.uploadFile为对象类型, 多文件上传时为数组类型, 
        // 单文件上传时也将files.uploadFile变成数组类型当做多文件上传处理;
        if (Object.prototype.toString.call(files.uploadFile) === '[object Object]') {

            files.uploadFile = [files.uploadFile];
            // var fileName = files.uploadFile.name; // 单文件上传时直接.name就可以得到文件名

        }


        let err_msg = ''
        for (let file of files.uploadFile) {
            var fileName = file.name;

            console.log("上传文件名: ", fileName);


            var suffix = fileName.slice(fileName.lastIndexOf('.'));

            var oldPath = file.path;
            var newPath = uploadDir + fileName;

            // log(oldPath)
            // log(newPath)


            // 如果不允许覆盖同名文件
            if (fields.allowCoverage !== 'true') {
                // 并且文件已经存在,那么在文件后面加上时间戳再加文件后缀
                if (fs.existsSync(newPath)) {
                    newPath = newPath + '-' + Date.now() + suffix;
                }
            }

            // 文件会被formidable自动保存, 而且文件名随机, 因此保存后需要改名
            fs.rename(oldPath, newPath, function(err) {
                if (err) {
                    console.log("err: ", err);
                    err_msg += JSON.stringify(err) + '\n';
                }
            })
        }

        //res.writeHead(200, { 'content-type': 'text/plain;charset=UTF-8' });
        // res.writeHead(301, { 'Location': '/' });
        res.end(err_msg);

    });
}


// 根据文件名删除文件
function deleteFile(req, res) {
    let url = decodeURI(req.url);
    let fileName = url.slice(url.indexOf('?') + 1);
    console.log("删除文件: ", fileName)

    fs.unlink(uploadDir + fileName, (err) => {
        if (err) {
            console.log(err);
            res.end('delete fail: ' + JSON.stringify(err));
            return;
        }
        res.end();
    });
}


// 根据文件名和数据修改(覆盖)文本文件
function modifyTextFile(req, res) {
    let url = decodeURI(req.url);
    let fileName = url.slice(url.indexOf('?') + 1);
    console.log("修改(覆盖)文本文件: ", fileName)

    let WriteStream = fs.createWriteStream(uploadDir + fileName)

    WriteStream.on('error', function(err) {
        res.end(JSON.stringify({ code: 1, msg: JSON.stringify(err) }))
    })

    WriteStream.on('finish', function() {
        res.end(JSON.stringify({ code: 0, msg: "保存成功" }))
    })

    req.on('data', function(data) {
        WriteStream.write(data)
    })

    req.on('end', function() {
        WriteStream.end()
        WriteStream.close()
    })
}



module.exports = {
    identityVerify,
    cookieVerify,
    getAllFileInfo,
    uploadFile,
    deleteFile,
    modifyTextFile
}

index.html文件的所有代码:


<!doctype html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="shortcut icon" href="./public/favicon.ico" type="image/x-icon">
    <title>zp文件服务器</title>
    <script src="./public/js/jq.js"></script>
    <link rel="stylesheet" href="./public/css/index.css">
</head>

<body>

    <form id="uploadFileForm" name="uploadFileForm">
        <h3>
            上传文件
            <label for="allowCoverage" style="font-size: 16px;margin-right: 15px;">
                <input type="checkbox" name="allowCoverage" value="true" id="allowCoverage">允许覆盖同名文件
            </label>
            <button type="button" class="btn btn-radius30" onclick="clearCookie()">退出</button>
        </h3>
        <div>
        </div>
        <input type="file" id="fileSelect" name="uploadFile" multiple>
        <button type="button" id="uploadFileBtn" style="color: blue;">上传文件</button>
        <div id="uploading" hidden style="margin-top: 10px;color: red;">文件上传中, 请稍等...</div>
    </form>

    <h3 style="margin-left: 15px;">文件列表(<span id="fileCount">0</span>个文件):</h3>
    <table>
        <thead>
            <tr>
                <th onclick="sort('src')">文件名</th>
                <th onclick="sort('size')">文件大小</th>
                <th onclick="sort('mtimeMs')">修改时间</th>
                <th>操作</th>
            </tr>
        </thead>
        <tbody id='allFile'>

        </tbody>
    </table>


    <script>
        // 前端限制上传的文件最大为1GB, 程序员可以在浏览器控制台修改maxFileSize的值用来上传更大的文件, 后端限制最大10GB
        let maxFileSize = 1 * 1024 * 1024 * 1024;

        let fileCount = document.getElementById('fileCount');

        let AllFileData = [];
        window.onload = function() {
            // 获取文件信息
            $.ajax({
                url: '/getAllFileInfo',
                type: 'get',
                success(d) {
                    d = JSON.parse(d);
                    console.log("文件信息: ", d);
                    AllFileData = d;
                    // 显示文件总数
                    fileCount.textContent = AllFileData.length;
                    renderFileList(d)
                },
                error(err) {
                    alert(err);
                    console.log("err: ", err);
                }
            })
        }

        // 上传文件
        uploadFileBtn.onclick = function() {

            if (!fileSelect.files.length) {
                alert('请先选择文件')
                return;
            }

            let allUploadFileSize = 0;
            Array.from(fileSelect.files).forEach(file => {
                allUploadFileSize += file.size;
                console.log(file.size)
            })

            if (allUploadFileSize > maxFileSize) {
                alert('文件总大小大于1GB, 无法上传')
                return;
            }

            if (allUploadFileSize > 10 * 1024 * 1024 * 1024) { // 后端限制文件最大为10GB
                alert('文件总大小大于10GB, 前端改代码也无法上传...')
                return;
            }

            // 防止快速连续多次点击导致重复上传
            uploadFileBtn.disabled = true;
            uploading.hidden = false;

            var fd = new FormData(document.forms['uploadFileForm']);
            $.ajax({
                url: '/uploadFile',
                type: 'post',
                data: fd,
                contentType: false, // 取消自动的设置请求头
                processData: false, //取消自动格式化数据
                enctype: 'multipart/form-data',
                success(d) {
                    if (d) {
                        alert(d)
                    } else {
                        location.reload();
                    }
                    console.log("d: ", d);
                },
                error(err) {
                    console.log("err: ", err);
                    alert(err.responseText)
                },
                complete() {
                    uploadFileBtn.disabled = false;
                    uploading.hidden = true;
                }
            })
        }



        // 文件删除和下载
        allFile.onclick = function(e) { // 给所有按钮的父元素添加点击事件

            if (e.target.tagName !== 'BUTTON') return;

            var fileName = e.target.dataset.filename;
            var className = e.target.className;

            // 删除
            if (className.includes('btn-delete')) {
                if (!confirm("确认删除?")) return;

                $.ajax({
                    url: '/deleteFile?' + fileName,
                    type: 'get',
                    success(d) {
                        console.log("d: ", d);
                        if (d) {
                            alert(d);
                        } else {
                            e.target.parentElement.parentElement.remove();
                            fileCount.textContent = ~~fileCount.textContent - 1; // 页面显示的文件总数减一
                        }
                    },
                    error(err) {
                        console.log("err: ", err);
                    }
                })

            } else if (className.includes('btn-download')) { // 下载
                let a_tag = document.createElement('a')
                a_tag.href = fileName;
                a_tag.download = fileName;
                a_tag.click()
                a_tag = null
            }

        }


        function clearCookie() {

            let cookie = document.cookie;
            let cookieArray = [];
            if (cookie) {
                cookieArray = cookie.split(';')
            }
            console.log(cookie);
            console.log(cookieArray);

            cookieArray.map(function(item) {
                let itemCookie = item.trim().split('=');
                const cookie_key = itemCookie[0];
                const cookie_value = itemCookie[1];
                document.cookie = cookie_key + '=0;expires=' + new Date(0).toUTCString()
            })
            location.reload();
        }


        // 标记当前是从小到大排序还是从大到小排序, 文件名、大小、修改时间都使用sort_flag标记, 懒得单独记录它们的状态了
        let sort_flag = false;

        // 排序
        function sort(type) {
            let data = JSON.parse(JSON.stringify(AllFileData)) // 深拷贝数组

            sort_flag = !sort_flag;

            // 选择排序
            for (let i = 0; i < data.length - 1; i++) {
                for (let j = i + 1; j < data.length; j++) {

                    if (sort_flag) {
                        if (data[j][type] > data[i][type]) {
                            swap(data, i, j)
                        }
                    } else {
                        if (data[j][type] < data[i][type]) {
                            swap(data, i, j)
                        }
                    }
                }

                // 更新文件列表
                renderFileList(data)
            }
        }

        // 两数交换
        function swap(data, i, j) {
            let temp;

            temp = data[i].src;
            data[i].src = data[j].src;
            data[j].src = temp;

            temp = data[i].size;
            data[i].size = data[j].size;
            data[j].size = temp;

            temp = data[i].mtimeMs;
            data[i].mtimeMs = data[j].mtimeMs;
            data[j].mtimeMs = temp;
        }


        // 将数据拼接成HTML然后添加到页面上
        function renderFileList(dataArray) {
            var fileListHTML = '';
            for (let item of dataArray) {
                // href='${item.src}'
                fileListHTML += `
                    <tr>
                        <td>
                            <a
                                title='${item.src}' 
                                onclick="processingResource('${item.src}')">
                                    ${item.src}
                                </a>
                            </td>
                        <td>${fileSizeFormat(item.size)}</td>
                        <td>${modifyTimeFormat(item.mtimeMs)}</td>
                        <td>
                            <button data-fileName='${item.src}' class="btn-download">下载</button>
                            <button data-fileName='${item.src}' class="btn-delete">删除</button>
                        </td>
                    </tr>`
            }
            document.getElementById('allFile').innerHTML = fileListHTML;
        }


        // 根据文件后缀判断文件类型, mp3、mp4类型的文件会打开播放页面, 常见的文本文件会打开编辑页面
        function processingResource(src) {
            console.log(src);
            let hasSuffix = src.lastIndexOf('.');
            let suffix; // 文件后缀
            if (hasSuffix > -1) {
                suffix = src.slice(hasSuffix + 1).toLocaleLowerCase();
                console.log(suffix);
            }
            switch (suffix) {
                case "mp3":
                    window.open("./public/playMusic.html?" + src)
                    break;
                case "mp4":
                    window.open("./public/playVideo.html?" + src)
                    break;
                case "txt":
                case "css":
                case "js":
                case "java":
                    window.open("./public/editText.html?" + src)
                    break;
                default:
                    window.open(src)
                    break;
            }

        }

        // 文件大小的格式, 为了程序可读性就直接写1024而不是1024的乘除结果(例如1024*1024可以写成1048576,运行会更快一点)
        function fileSizeFormat(fileSize) {
            fileSize = Number(fileSize);
            if (fileSize < 1024) {
                return fileSize.toFixed(2) + 'B'
            } else if (fileSize < 1024 * 1024) {
                return (fileSize / 1024).toFixed(2) + 'KB'
            } else if (fileSize < 1024 * 1024 * 1024) {
                return (fileSize / 1024 / 1024).toFixed(2) + 'MB'
            } else {
                return (fileSize / 1024 / 1024 / 1024).toFixed(2) + 'GB'
            }
        }

        // 修改时间显示的格式
        function modifyTimeFormat(mtimeMs) {
            return new Date(mtimeMs).toLocaleString()
        }
    </script>


</body>

</html>

项目部署至云服务器

1.购买云服务器,阿里云学生机只要9.5元/月:promotion.aliyun.com/ntms/act/ca…

2.在云服务器上安装Node.js

3.安装Forever
Forever可以守护Node.js应用,客户端断开的情况下,应用也能正常工作。

[sudo] npm install forever -g

4.安装项目依赖(formidable)
进入到项目的根目录,输入:

npm install

5.运行项目
进入到项目的根目录,输入:

forever start app.js

6.使用项目功能
接着打开浏览器输入云服务器的ip地址(或域名)+本项目的运行端口号(我的端口号是3008)就可以使用在线版的文件服务器了: