mirror of
https://github.com/koodo-reader/koodo-reader.git
synced 2026-02-19 15:48:05 -05:00
438 lines
13 KiB
JavaScript
438 lines
13 KiB
JavaScript
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const url = require("url");
|
||
|
||
// 从 Docker Secrets 读取密码的函数
|
||
function getDockerSecret(secretName) {
|
||
try {
|
||
const secretPath = `/run/secrets/${secretName}`;
|
||
if (fs.existsSync(secretPath)) {
|
||
return fs.readFileSync(secretPath, "utf8").trim();
|
||
}
|
||
} catch (err) {
|
||
console.warn(`Failed to read Docker secret '${secretName}':`, err.message);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 配置信息
|
||
const UPLOAD_DIR = path.resolve("./uploads"); // 使用绝对路径
|
||
const PORT = process.env.PORT || 8080;
|
||
const SERVER_ENABLED = process.env.ENABLE_HTTP_SERVER === "true"; // 新增:控制服务器是否启用
|
||
// 优先从 Docker Secrets 读取密码,如果没有则回退到环境变量
|
||
const SERVER_PASSWORD_FILE = process.env.SERVER_PASSWORD_FILE || "my_secret";
|
||
const SERVER_PASSWORD =
|
||
getDockerSecret(SERVER_PASSWORD_FILE) ||
|
||
process.env.SERVER_PASSWORD ||
|
||
"securePass123";
|
||
const VALID_CREDENTIALS = {
|
||
username: process.env.SERVER_USERNAME || "admin",
|
||
password: SERVER_PASSWORD,
|
||
};
|
||
|
||
// 验证密码来源
|
||
if (getDockerSecret(SERVER_PASSWORD_FILE)) {
|
||
console.log("Using password from Docker Secret");
|
||
} else if (process.env.SERVER_PASSWORD) {
|
||
console.warn("Using password from environment variable (less secure)");
|
||
} else {
|
||
console.warn(
|
||
"Warning: Using default password. Set Docker Secret or SERVER_PASSWORD environment variable for production."
|
||
);
|
||
}
|
||
|
||
if (!process.env.SERVER_USERNAME) {
|
||
console.warn(
|
||
"Warning: Using default username. Set SERVER_USERNAME environment variable for production."
|
||
);
|
||
}
|
||
|
||
// 检查服务器是否启用
|
||
if (!SERVER_ENABLED) {
|
||
console.log(
|
||
"HTTP Server is disabled. Set ENABLE_HTTP_SERVER=true to enable it."
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
// 创建上传目录(如果不存在)
|
||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
// 设置CORS头部
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||
|
||
// 处理预检请求
|
||
if (req.method === "OPTIONS") {
|
||
res.writeHead(200);
|
||
return res.end();
|
||
}
|
||
// 认证检查
|
||
if (!authenticate(req)) {
|
||
res.writeHead(401, {
|
||
"WWW-Authenticate": 'Basic realm="Secure File Server"',
|
||
"Content-Type": "text/plain",
|
||
});
|
||
return res.end("Unauthorized");
|
||
}
|
||
|
||
// 路由处理
|
||
const parsedUrl = url.parse(req.url, true);
|
||
|
||
if (req.method === "POST" && parsedUrl.pathname === "/upload") {
|
||
handleUpload(req, res, parsedUrl.query.dir || "");
|
||
} else if (req.method === "GET" && parsedUrl.pathname === "/download") {
|
||
handleDownload(req, res, parsedUrl.query.dir || "");
|
||
} else if (req.method === "DELETE" && parsedUrl.pathname === "/delete") {
|
||
handleDelete(req, res, parsedUrl.query.dir || "");
|
||
} else if (req.method === "GET" && parsedUrl.pathname === "/list") {
|
||
handleList(req, res, parsedUrl.query.dir || "");
|
||
} else {
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
res.end("Not Found");
|
||
}
|
||
});
|
||
|
||
// 基本认证验证
|
||
function authenticate(req) {
|
||
const authHeader = req.headers["authorization"];
|
||
if (!authHeader) return false;
|
||
|
||
const [scheme, credentials] = authHeader.split(" ");
|
||
if (scheme !== "Basic") return false;
|
||
|
||
const [username, password] = Buffer.from(credentials, "base64")
|
||
.toString()
|
||
.split(":");
|
||
|
||
return (
|
||
username === VALID_CREDENTIALS.username &&
|
||
password === VALID_CREDENTIALS.password
|
||
);
|
||
}
|
||
|
||
// 安全处理文件名
|
||
function sanitizeFilename(originalName) {
|
||
// 移除路径部分,只保留文件名
|
||
const base = path.basename(originalName);
|
||
|
||
// 替换非法字符(Windows文件系统非法字符)
|
||
return base.replace(/[\\/:*?"<>|]/g, "_");
|
||
}
|
||
|
||
// 安全路径解析(防止目录遍历攻击)
|
||
function resolveSafePath(...pathSegments) {
|
||
const targetPath = path.resolve(UPLOAD_DIR, ...pathSegments);
|
||
const relativePath = path.relative(UPLOAD_DIR, targetPath);
|
||
|
||
// 检查路径是否试图访问UPLOAD_DIR之外
|
||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||
throw new Error("Invalid path");
|
||
}
|
||
|
||
return targetPath;
|
||
}
|
||
|
||
// 文件上传处理
|
||
function handleUpload(req, res, dirParam) {
|
||
const contentType = req.headers["content-type"];
|
||
if (!contentType || !contentType.includes("multipart/form-data")) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Invalid Content-Type. Expected multipart/form-data");
|
||
}
|
||
|
||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||
if (!boundaryMatch) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Missing boundary in Content-Type");
|
||
}
|
||
|
||
const boundary = boundaryMatch[1];
|
||
let body = [];
|
||
|
||
req.on("data", (chunk) => body.push(chunk));
|
||
req.on("end", () => {
|
||
try {
|
||
const buffer = Buffer.concat(body);
|
||
const parts = parseMultipart(buffer, boundary);
|
||
|
||
if (!parts.file || !parts.filename) {
|
||
console.log("Parsed parts:", Object.keys(parts)); // 调试信息
|
||
throw new Error("No valid file uploaded");
|
||
}
|
||
|
||
// 安全处理文件名
|
||
const safeFilename = sanitizeFilename(parts.filename);
|
||
|
||
// 验证文件名
|
||
if (!safeFilename || safeFilename === "." || safeFilename === "..") {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Invalid filename");
|
||
}
|
||
|
||
// 解析并验证目标路径
|
||
const targetDir = resolveSafePath(dirParam);
|
||
const filePath = resolveSafePath(dirParam, safeFilename);
|
||
|
||
// 确保目标目录存在
|
||
if (!fs.existsSync(targetDir)) {
|
||
fs.mkdirSync(targetDir, { recursive: true });
|
||
}
|
||
|
||
fs.writeFile(filePath, parts.file, (err) => {
|
||
if (err) {
|
||
console.error("File write error:", err);
|
||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||
return res.end("Internal Server Error");
|
||
}
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
filename: safeFilename,
|
||
directory: dirParam,
|
||
message: "File uploaded successfully",
|
||
})
|
||
);
|
||
});
|
||
} catch (err) {
|
||
console.error("Upload error:", err);
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
res.end(err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 解析multipart数据 - 改进版本
|
||
function parseMultipart(buffer, boundary) {
|
||
const result = {};
|
||
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
||
const parts = [];
|
||
|
||
let start = 0;
|
||
let end = buffer.indexOf(boundaryBuffer, start);
|
||
|
||
while (end !== -1) {
|
||
if (start !== 0) {
|
||
// 跳过第一个边界前的内容
|
||
parts.push(buffer.slice(start, end));
|
||
}
|
||
start = end + boundaryBuffer.length;
|
||
end = buffer.indexOf(boundaryBuffer, start);
|
||
}
|
||
|
||
for (const part of parts) {
|
||
if (part.length === 0) continue;
|
||
|
||
// 查找头部结束位置
|
||
const headerEndIndex = part.indexOf("\r\n\r\n");
|
||
if (headerEndIndex === -1) continue;
|
||
|
||
const headers = part.slice(0, headerEndIndex).toString();
|
||
const content = part.slice(headerEndIndex + 4);
|
||
|
||
// 移除结尾的 \r\n
|
||
const actualContent = content.slice(0, content.length - 2);
|
||
|
||
const nameMatch = headers.match(/name="([^"]+)"/);
|
||
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
||
|
||
if (nameMatch) {
|
||
const name = nameMatch[1];
|
||
if (filenameMatch && filenameMatch[1]) {
|
||
result.filename = filenameMatch[1];
|
||
result.file = actualContent;
|
||
console.log(
|
||
`Found file: ${result.filename}, size: ${actualContent.length} bytes`
|
||
); // 调试信息
|
||
} else {
|
||
result[name] = actualContent.toString();
|
||
}
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
// 文件下载处理
|
||
function handleDownload(req, res, dirParam) {
|
||
try {
|
||
const parsedUrl = url.parse(req.url, true);
|
||
const filename = parsedUrl.query.filename;
|
||
|
||
if (!filename) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Missing filename parameter");
|
||
}
|
||
|
||
// 安全处理文件名
|
||
const safeFilename = sanitizeFilename(filename);
|
||
|
||
// 验证文件名
|
||
if (!safeFilename || safeFilename === "." || safeFilename === "..") {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Invalid filename");
|
||
}
|
||
|
||
// 解析并验证文件路径
|
||
const filePath = resolveSafePath(dirParam, safeFilename);
|
||
|
||
// 检查文件是否存在
|
||
if (!fs.existsSync(filePath)) {
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
return res.end("File not found");
|
||
}
|
||
|
||
const stat = fs.statSync(filePath);
|
||
|
||
// 设置下载文件名
|
||
const encodedFilename = encodeURIComponent(safeFilename);
|
||
res.writeHead(200, {
|
||
"Content-Type": "application/octet-stream",
|
||
"Content-Length": stat.size,
|
||
"Content-Disposition": `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`,
|
||
});
|
||
|
||
fs.createReadStream(filePath).pipe(res);
|
||
} catch (err) {
|
||
console.error("Download error:", err);
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
res.end(err.message);
|
||
}
|
||
}
|
||
function handleDelete(req, res, dirParam) {
|
||
try {
|
||
const parsedUrl = url.parse(req.url, true);
|
||
const filename = parsedUrl.query.filename;
|
||
|
||
if (!filename) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Missing filename parameter");
|
||
}
|
||
|
||
// 安全处理文件名
|
||
const safeFilename = sanitizeFilename(filename);
|
||
|
||
// 验证文件名
|
||
if (!safeFilename || safeFilename === "." || safeFilename === "..") {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Invalid filename");
|
||
}
|
||
|
||
// 解析并验证文件路径
|
||
const filePath = resolveSafePath(dirParam, safeFilename);
|
||
|
||
// 检查文件是否存在
|
||
if (!fs.existsSync(filePath)) {
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
return res.end("File not found");
|
||
}
|
||
|
||
// 检查是否为文件(非目录)
|
||
const stat = fs.statSync(filePath);
|
||
if (!stat.isFile()) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Target is not a file");
|
||
}
|
||
|
||
// 删除文件
|
||
fs.unlink(filePath, (err) => {
|
||
if (err) {
|
||
console.error("File delete error:", err);
|
||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||
return res.end("Internal Server Error");
|
||
}
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
filename: safeFilename,
|
||
directory: dirParam,
|
||
message: "File deleted successfully",
|
||
})
|
||
);
|
||
});
|
||
} catch (err) {
|
||
console.error("Delete error:", err);
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
res.end(err.message);
|
||
}
|
||
}
|
||
|
||
// 目录列表处理
|
||
function handleList(req, res, dirParam) {
|
||
try {
|
||
// 解析并验证目录路径
|
||
const targetDir = resolveSafePath(dirParam);
|
||
|
||
// 检查目录是否存在
|
||
if (!fs.existsSync(targetDir)) {
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
return res.end("Directory not found");
|
||
}
|
||
|
||
// 检查是否为目录
|
||
const stat = fs.statSync(targetDir);
|
||
if (!stat.isDirectory()) {
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
return res.end("Target is not a directory");
|
||
}
|
||
|
||
// 读取目录内容
|
||
fs.readdir(targetDir, { withFileTypes: true }, (err, entries) => {
|
||
if (err) {
|
||
console.error("Directory read error:", err);
|
||
res.writeHead(500, { "Content-Type": "text/plain" });
|
||
return res.end("Internal Server Error");
|
||
}
|
||
|
||
const fileList = entries.map((entry) => {
|
||
const stat = fs.statSync(path.join(targetDir, entry.name));
|
||
return {
|
||
name: entry.name,
|
||
type: entry.isDirectory() ? "directory" : "file",
|
||
size: entry.isFile() ? stat.size : null,
|
||
modifiedTime: stat.mtime.toISOString(),
|
||
createdTime: stat.birthtime.toISOString(),
|
||
};
|
||
});
|
||
|
||
// 按类型和名称排序(目录在前,然后按名称排序)
|
||
fileList.sort((a, b) => {
|
||
if (a.type !== b.type) {
|
||
return a.type === "directory" ? -1 : 1;
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
res.end(
|
||
JSON.stringify({
|
||
success: true,
|
||
directory: dirParam,
|
||
files: fileList,
|
||
totalCount: fileList.length,
|
||
})
|
||
);
|
||
});
|
||
} catch (err) {
|
||
console.error("List error:", err);
|
||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||
res.end(err.message);
|
||
}
|
||
}
|
||
|
||
// 启动服务器
|
||
server.listen(PORT, () => {
|
||
console.log(`Secure File Server running at http://localhost:${PORT}`);
|
||
console.log(`Username: ${VALID_CREDENTIALS.username}`);
|
||
console.log("Password: [HIDDEN FOR SECURITY]");
|
||
});
|