#!/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 ? `
` : `这里空空如也,上传一个文件吧
`}
`;
}
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");
}