#!/usr/bin/env node /** * selfcloud-js - 跑在你自己服务器上的极简云盘(Node.js 公网版) * ============================================================= * 单文件、依赖仅 express / express-session / multer。 * * 用法: * npm install * export CLOUD_PASSWORD="你的强密码" * export SSL_CERT=/etc/letsencrypt/live/你的域名/fullchain.pem * export SSL_KEY=/etc/letsencrypt/live/你的域名/privkey.pem * node app.js * * 环境变量: * CLOUD_DIR 存储目录,默认 ./storage * CLOUD_PASSWORD 登录密码,默认 admin123(必须改) * CLOUD_PORT 端口,默认 8000 * SSL_CERT https 证书 fullchain.pem 路径(公网建议配) * SSL_KEY https 私钥 privkey.pem 路径 */ const express = require("express"); const session = require("express-session"); const multer = require("multer"); const https = require("https"); const http = require("http"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); // ---------- 配置 ---------- const STORAGE = path.resolve(process.env.CLOUD_DIR || "./storage"); const PASSWORD = process.env.CLOUD_PASSWORD || "admin123"; const PORT = parseInt(process.env.CLOUD_PORT || "8000", 10); const SSL_CERT = process.env.SSL_CERT; const SSL_KEY = process.env.SSL_KEY; const SECRET = process.env.CLOUD_SECRET || crypto.randomBytes(16).toString("hex"); const SHARE_FILE = path.join(STORAGE, ".shares.json"); fs.mkdirSync(STORAGE, { recursive: true }); // ---------- 分享 token 存取 ---------- function loadShares() { try { return JSON.parse(fs.readFileSync(SHARE_FILE, "utf-8")); } catch { return {}; } } function saveShares(d) { fs.writeFileSync(SHARE_FILE, JSON.stringify(d, null, 2), "utf-8"); } // ---------- 工具 ---------- function safeJoin(rel) { const p = path.resolve(STORAGE, rel || ""); if (!p.startsWith(STORAGE)) { const e = new Error("forbidden"); e.status = 403; throw e; } return p; } function humanSize(n) { n = Number(n); const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(1)} ${u[i]}`; } // ---------- 上传配置:直接落盘到当前目录 ---------- const upload = multer({ storage: multer.diskStorage({ destination(req, _file, cb) { const pwd = (req.body.pwd || "").replace(/^\/+/, ""); cb(null, safeJoin(pwd)); }, filename(_req, file, cb) { // 防路径穿越:只取 basename cb(null, path.basename(file.originalname)); }, }), limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10GB }); // ---------- 页面模板 ---------- const LOGIN_HTML = ` 登录 - 我的云盘

☁️ 我的云盘

{{ERR}}
`; function indexHTML({ entries, pwd, crumbs, flashes }) { const flashHtml = (flashes || []) .map(([cat, msg]) => `
${escapeHtml(msg)}
`) .join(""); const rows = entries.map((e) => ` ${e.is_dir ? "📁" : "📄"} ${e.is_dir ? `${escapeHtml(e.name)}` : escapeHtml(e.name)} ${e.size} ${e.mtime} ${!e.is_dir ? ` 下载 ${e.share_token ? `公开链接
` : `
`} ` : ""}
`).join(""); const crumbHtml = crumbs.map((c) => ` / ${escapeHtml(c.name)}`).join(""); return ` 我的云盘

☁️ 我的云盘

${flashHtml}
📁 根目录${crumbHtml} ${pwd ? ` / ${escapeHtml(pwd.split("/").pop())}` : ""}
${entries.length ? ` ${rows}
名称大小修改时间操作
` : `
这里空空如也,上传一个文件吧
`} `; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // ---------- App ---------- const app = express(); app.use(express.urlencoded({ extended: false })); app.use(session({ secret: SECRET, resave: false, saveUninitialized: false, cookie: { maxAge: 7 * 24 * 3600 * 1000 }, })); // 简易 flash app.use((req, _res, next) => { req.flashes = req.session.flashes || []; req.session.flashes = []; req.flash = (cat, msg) => req.session.flashes.push([cat, msg]); next(); }); function loginRequired(req, res, next) { if (!req.session.auth) return res.redirect("/login?next=" + encodeURIComponent(req.originalUrl)); next(); } // ---------- 路由 ---------- app.get("/login", (req, res) => { res.send(LOGIN_HTML.replace("{{ERR}}", "")); }); app.post("/login", (req, res) => { if (req.body.password === PASSWORD) { req.session.auth = true; return res.redirect(req.query.next || "/"); } res.send(LOGIN_HTML.replace("{{ERR}}", `
密码错误
`)); }); app.get("/logout", (req, res) => { req.session.destroy(() => res.redirect("/login")); }); app.get("/", loginRequired, (req, res) => { const pwd = (req.query.p || "").toString().replace(/^\/+/, ""); const here = safeJoin(pwd); if (!fs.existsSync(here) || !fs.statSync(here).isDirectory()) return res.status(404).send("not found"); const shares = loadShares(); const entries = []; if (pwd) { entries.push({ name: "..", is_dir: true, rel: path.dirname(pwd) === "." ? "" : path.dirname(pwd), size: "-", mtime: "-", share_token: null, }); } for (const name of fs.readdirSync(here)) { if (name.startsWith(".")) continue; const full = path.join(here, name); const st = fs.statSync(full); const rel = pwd ? path.join(pwd, name) : name; entries.push({ name, is_dir: st.isDirectory(), rel, size: st.isDirectory() ? "-" : humanSize(st.size), mtime: new Date(st.mtimeMs).toISOString().slice(0, 16).replace("T", " "), share_token: shares[rel] || null, }); } entries.sort((a, b) => (a.is_dir === b.is_dir ? a.name.localeCompare(b.name) : a.is_dir ? -1 : 1)); const crumbs = []; pwd.split("/").filter(Boolean).reduce((acc, part) => { const p = acc ? acc + "/" + part : part; crumbs.push({ name: part, path: p }); return p; }, ""); res.send(indexHTML({ entries, pwd, crumbs, flashes: req.flashes })); }); app.post("/upload", loginRequired, upload.single("file"), (req, res) => { const pwd = (req.body.pwd || "").replace(/^\/+/, ""); req.flash("ok", `已上传: ${req.file.filename}`); res.redirect("/?p=" + encodeURIComponent(pwd)); }); app.post("/mkdir", loginRequired, (req, res) => { const pwd = (req.body.pwd || "").replace(/^\/+/, ""); const name = path.basename((req.body.name || "").trim()); if (!name) { req.flash("err", "文件夹名不能为空"); return res.redirect("/?p=" + encodeURIComponent(pwd)); } fs.mkdirSync(path.join(safeJoin(pwd), name), { recursive: true }); req.flash("ok", `已创建文件夹: ${name}`); res.redirect("/?p=" + encodeURIComponent(pwd)); }); app.get("/download", loginRequired, (req, res) => { const rel = (req.query.p || "").toString(); const p = safeJoin(rel); if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).send("not found"); res.download(p, path.basename(p)); }); app.post("/share", loginRequired, (req, res) => { const rel = (req.query.p || "").toString(); const p = safeJoin(rel); if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).send("not found"); const shares = loadShares(); const token = crypto.randomBytes(12).toString("urlsafe"); shares[rel] = token; saveShares(shares); req.flash("ok", `分享链接已生成: ${req.protocol}://${req.get("host")}/s/${token}`); res.redirect("/?p=" + encodeURIComponent(path.dirname(rel))); }); app.post("/unshare", loginRequired, (req, res) => { const rel = (req.query.p || "").toString(); const shares = loadShares(); delete shares[rel]; saveShares(shares); req.flash("ok", `已取消分享: ${rel}`); res.redirect("/?p=" + encodeURIComponent(path.dirname(rel))); }); app.get("/s/:token", (req, res) => { const shares = loadShares(); const rel = Object.keys(shares).find((r) => shares[r] === req.params.token); if (!rel) return res.status(404).send("链接无效或已取消分享"); const p = safeJoin(rel); if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).send("文件不存在"); res.download(p, path.basename(p)); }); app.post("/remove", loginRequired, (req, res) => { const pwd = (req.body.pwd || "").replace(/^\/+/, ""); const name = req.body.name || ""; if (!name || name === "..") return res.status(400).send("bad request"); const target = path.join(safeJoin(pwd), name); if (target === STORAGE) return res.status(403).send("forbidden"); fs.rmSync(target, { recursive: true, force: true }); const rel = pwd ? path.join(pwd, name) : name; const shares = loadShares(); delete shares[rel]; saveShares(shares); req.flash("ok", `已删除: ${name}`); res.redirect("/?p=" + encodeURIComponent(pwd)); }); // ---------- 启动 ---------- const useHttps = SSL_CERT && SSL_KEY; console.log("☁️ selfcloud-js 启动"); console.log(" 存储目录:", STORAGE); console.log(" 监听端口:", PORT); console.log(" 协议: ", useHttps ? "https" : "http ⚠️ 公网建议配置 SSL_CERT/SSL_KEY"); console.log(" 密码: ", PASSWORD, "(改 CLOUD_PASSWORD)"); if (useHttps) { https.createServer({ cert: fs.readFileSync(SSL_CERT), key: fs.readFileSync(SSL_KEY), }, app).listen(PORT, "0.0.0.0"); } else { http.createServer(app).listen(PORT, "0.0.0.0"); }