Files
sumi.work/server/server.js
T

618 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import multer from "multer";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const dataDir = path.join(__dirname, "data");
const uploadDir = path.join(__dirname, "uploads");
const packageDir = path.join(uploadDir, "packages");
const iconDir = path.join(uploadDir, "icons");
const dataFile = path.join(dataDir, "software.json");
const playerFile = path.join(dataDir, "players.json");
const port = Number(process.env.PORT || 3030);
const adminToken = process.env.ADMIN_TOKEN || "sumi-admin";
const playerSessionCookie = "sumi_player_session";
const playerSessionTtlMs = Number(process.env.PLAYER_SESSION_TTL_MS || 7 * 24 * 60 * 60 * 1000);
const passwordIterations = 120000;
const app = express();
const playerSessions = new Map();
function slugify(value) {
const slug = String(value || "")
.normalize("NFKD")
.replace(/[^\w\s.-]/g, "")
.trim()
.replace(/[\s_.]+/g, "-")
.replace(/-+/g, "-")
.toLowerCase()
.slice(0, 64);
return slug || `software-${Date.now()}`;
}
function parseBoolean(value, fallback = false) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "on", "yes"].includes(String(value).toLowerCase());
}
function parseTags(value) {
if (!value) return [];
if (Array.isArray(value)) return value.flatMap(parseTags);
return String(value)
.split(/[,\n]/)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 8);
}
function formatSize(bytes = 0) {
const units = ["B", "KB", "MB", "GB"];
let size = Number(bytes);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
async function ensureStorage() {
await Promise.all([
fsp.mkdir(dataDir, { recursive: true }),
fsp.mkdir(packageDir, { recursive: true }),
fsp.mkdir(iconDir, { recursive: true }),
]);
try {
await fsp.access(dataFile);
} catch {
await writeStore({ items: [] });
}
try {
await fsp.access(playerFile);
} catch {
await writePlayers({
players: [
createPlayerRecord(process.env.PLAYER_USERNAME || "player", process.env.PLAYER_PASSWORD || "sumi-player"),
],
});
}
if (process.env.PLAYER_USERNAME && process.env.PLAYER_PASSWORD) {
await upsertEnvPlayer();
}
}
async function readStore() {
const raw = await fsp.readFile(dataFile, "utf8");
const parsed = JSON.parse(raw || "{}");
return { items: Array.isArray(parsed.items) ? parsed.items : [] };
}
async function writeStore(store) {
const tmpFile = `${dataFile}.${process.pid}.tmp`;
await fsp.writeFile(tmpFile, `${JSON.stringify({ items: store.items }, null, 2)}\n`, "utf8");
await fsp.rename(tmpFile, dataFile);
}
async function readPlayers() {
const raw = await fsp.readFile(playerFile, "utf8");
const parsed = JSON.parse(raw || "{}");
return { players: Array.isArray(parsed.players) ? parsed.players : [] };
}
async function writePlayers(store) {
const tmpFile = `${playerFile}.${process.pid}.tmp`;
await fsp.writeFile(tmpFile, `${JSON.stringify({ players: store.players }, null, 2)}\n`, "utf8");
await fsp.rename(tmpFile, playerFile);
}
function createPlayerRecord(username, password, existing = {}) {
return {
...existing,
username,
displayName: existing.displayName || "玩家",
active: true,
passwordHash: hashPassword(password),
updatedAt: new Date().toISOString(),
createdAt: existing.createdAt || new Date().toISOString(),
};
}
async function upsertEnvPlayer() {
const username = process.env.PLAYER_USERNAME;
const password = process.env.PLAYER_PASSWORD;
const store = await readPlayers();
const index = store.players.findIndex((player) => player.username === username);
if (index === -1) {
store.players.push(createPlayerRecord(username, password));
} else {
const existing = store.players[index];
if (existing.active !== false && verifyPassword(password, existing.passwordHash)) {
return;
}
store.players[index] = createPlayerRecord(username, password, store.players[index]);
}
await writePlayers(store);
}
async function hashFile(filePath) {
const hash = crypto.createHash("sha256");
await new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", resolve);
});
return hash.digest("hex");
}
function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex");
return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`;
}
function verifyPassword(password, encoded = "") {
const [scheme, iterationText, salt, expectedHash] = String(encoded).split("$");
if (scheme !== "pbkdf2_sha256" || !iterationText || !salt || !expectedHash) return false;
const iterations = Number.parseInt(iterationText, 10);
const actual = crypto.pbkdf2Sync(String(password), salt, iterations, 32, "sha256");
const expected = Buffer.from(expectedHash, "hex");
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
function getToken(req) {
const auth = req.get("authorization") || "";
if (auth.toLowerCase().startsWith("bearer ")) return auth.slice(7).trim();
return req.get("x-admin-token") || req.query.token || "";
}
function parseCookies(req) {
return Object.fromEntries(
String(req.get("cookie") || "")
.split(";")
.map((pair) => pair.trim())
.filter(Boolean)
.map((pair) => {
const index = pair.indexOf("=");
if (index === -1) return [pair, ""];
return [pair.slice(0, index), decodeURIComponent(pair.slice(index + 1))];
}),
);
}
function isHttpsRequest(req) {
return req.secure || req.get("x-forwarded-proto") === "https";
}
function setSessionCookie(req, res, token) {
const secure = isHttpsRequest(req) ? "; Secure" : "";
res.setHeader(
"Set-Cookie",
`${playerSessionCookie}=${encodeURIComponent(token)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(playerSessionTtlMs / 1000)}${secure}`,
);
}
function clearSessionCookie(req, res) {
const secure = isHttpsRequest(req) ? "; Secure" : "";
res.setHeader("Set-Cookie", `${playerSessionCookie}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure}`);
}
function publicPlayer(player) {
if (!player) return null;
return {
username: player.username,
displayName: player.displayName || player.username,
};
}
function getPlayerSession(req) {
const token = parseCookies(req)[playerSessionCookie];
if (!token) return null;
const session = playerSessions.get(token);
if (!session) return null;
if (session.expiresAt <= Date.now()) {
playerSessions.delete(token);
return null;
}
session.expiresAt = Date.now() + playerSessionTtlMs;
return { token, session };
}
function requireAdmin(req, res, next) {
if (getToken(req) !== adminToken) {
res.status(401).json({ error: "Invalid admin token" });
return;
}
next();
}
function requirePlayer(req, res, next) {
const playerSession = getPlayerSession(req);
if (!playerSession) {
const loginUrl = `/login.html?next=${encodeURIComponent(req.originalUrl)}`;
const accept = req.get("accept") || "";
if (accept.includes("text/html") && !accept.includes("application/json")) {
res.redirect(302, loginUrl);
return;
}
res.status(401).json({ error: "Player login required", loginUrl });
return;
}
req.player = playerSession.session.player;
next();
}
function publicItem(item, includePrivate = false) {
const copy = { ...item };
if (!includePrivate) {
delete copy.storagePath;
delete copy.iconStoragePath;
delete copy.externalDownloadUrl;
if (resolveDownloadUrl(item)) {
copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`;
}
}
return copy;
}
function sortSoftware(items) {
return [...items].sort((a, b) => {
if (Boolean(a.featured) !== Boolean(b.featured)) return a.featured ? -1 : 1;
if ((a.sort ?? 100) !== (b.sort ?? 100)) return (a.sort ?? 100) - (b.sort ?? 100);
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
});
}
async function makeFilePatch(files = {}) {
const patch = {};
const packageFile = files.package?.[0];
const iconFile = files.icon?.[0];
if (packageFile) {
patch.storagePath = packageFile.path;
patch.originalFileName = packageFile.originalname;
patch.fileName = packageFile.filename;
patch.sizeBytes = packageFile.size;
patch.size = formatSize(packageFile.size);
patch.sha256 = await hashFile(packageFile.path);
patch.downloadUrl = `/uploads/packages/${encodeURIComponent(packageFile.filename)}`;
patch.externalDownloadUrl = "";
}
if (iconFile) {
patch.iconStoragePath = iconFile.path;
patch.iconFileName = iconFile.filename;
patch.iconUrl = `/uploads/icons/${encodeURIComponent(iconFile.filename)}`;
}
return patch;
}
function getBodyPatch(body, existing = {}) {
const now = new Date().toISOString();
return {
name: String(body.name || existing.name || "").trim(),
version: String(body.version || existing.version || "1.0.0").trim(),
category: String(body.category || existing.category || "tools").trim(),
description: String(body.description || existing.description || "").trim(),
changelog: String(body.changelog || existing.changelog || "").trim(),
publisher: String(body.publisher || existing.publisher || "sumi.work").trim(),
platform: String(body.platform || existing.platform || "Windows").trim(),
homepage: String(body.homepage || existing.homepage || "").trim(),
tags: parseTags(body.tags ?? existing.tags),
sort: Number.parseInt(body.sort ?? existing.sort ?? 100, 10),
active: parseBoolean(body.active, existing.active !== false),
featured: parseBoolean(body.featured, Boolean(existing.featured)),
externalDownloadUrl: String(body.downloadUrl || existing.externalDownloadUrl || "").trim(),
updatedAt: now,
};
}
function resolveDownloadUrl(item) {
return item.downloadUrl || item.externalDownloadUrl || "";
}
function isPathInside(parent, child) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolvePackagePath(item) {
if (item.storagePath) return path.resolve(item.storagePath);
if (item.downloadUrl?.startsWith("/uploads/packages/")) {
return path.resolve(packageDir, path.basename(decodeURIComponent(item.downloadUrl)));
}
return "";
}
const storage = multer.diskStorage({
destination(req, file, callback) {
callback(null, file.fieldname === "icon" ? iconDir : packageDir);
},
filename(req, file, callback) {
const ext = path.extname(file.originalname || "").slice(0, 24);
const base = slugify(path.basename(file.originalname || "upload", ext));
const suffix = crypto.randomBytes(6).toString("hex");
callback(null, `${Date.now()}-${suffix}-${base}${ext}`);
},
});
const upload = multer({
storage,
limits: {
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024),
files: 2,
},
fileFilter(req, file, callback) {
if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) {
callback(new Error("Icon must be an image file"));
return;
}
callback(null, true);
},
});
app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true }));
app.use("/uploads/icons", express.static(iconDir, { fallthrough: false }));
app.get("/api/health", (req, res) => {
res.json({ ok: true, service: "sumi-software-center" });
});
app.get("/api/auth/me", (req, res) => {
const playerSession = getPlayerSession(req);
if (!playerSession) {
res.json({ authenticated: false, player: null });
return;
}
setSessionCookie(req, res, playerSession.token);
res.json({ authenticated: true, player: publicPlayer(playerSession.session.player) });
});
app.post("/api/auth/login", async (req, res, next) => {
try {
const username = String(req.body.username || "").trim();
const password = String(req.body.password || "");
const nextUrl = String(req.body.next || "/software.html");
const { players } = await readPlayers();
const player = players.find((entry) => entry.username === username && entry.active !== false);
if (!player || !verifyPassword(password, player.passwordHash)) {
res.status(401).json({ error: "账号或密码错误" });
return;
}
const token = crypto.randomBytes(32).toString("base64url");
playerSessions.set(token, {
player: publicPlayer(player),
createdAt: Date.now(),
expiresAt: Date.now() + playerSessionTtlMs,
});
setSessionCookie(req, res, token);
res.json({ authenticated: true, player: publicPlayer(player), next: nextUrl });
} catch (error) {
next(error);
}
});
app.post("/api/auth/logout", (req, res) => {
const playerSession = getPlayerSession(req);
if (playerSession) {
playerSessions.delete(playerSession.token);
}
clearSessionCookie(req, res);
res.json({ authenticated: false });
});
app.get("/api/software", async (req, res, next) => {
try {
const { items } = await readStore();
const category = String(req.query.category || "all");
const keyword = String(req.query.keyword || "").trim().toLowerCase();
const visibleItems = items
.filter((item) => item.active !== false)
.filter((item) => category === "all" || !category || item.category === category)
.filter((item) => {
if (!keyword) return true;
return [item.name, item.description, item.publisher, ...(item.tags || [])]
.join(" ")
.toLowerCase()
.includes(keyword);
});
res.json({ items: sortSoftware(visibleItems).map((item) => publicItem(item)) });
} catch (error) {
next(error);
}
});
app.get("/api/software/:id/latest", async (req, res, next) => {
try {
const { items } = await readStore();
const item = items.find((entry) => entry.id === req.params.id && entry.active !== false);
if (!item) {
res.status(404).json({ error: "Software not found" });
return;
}
res.json({
id: item.id,
name: item.name,
version: item.version,
changelog: item.changelog,
updatedAt: item.updatedAt,
size: item.size,
sizeBytes: item.sizeBytes,
sha256: item.sha256,
downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`,
});
} catch (error) {
next(error);
}
});
app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => {
try {
const store = await readStore();
const item = store.items.find((entry) => entry.id === req.params.id && entry.active !== false);
if (!item) {
res.status(404).json({ error: "Software not found" });
return;
}
const downloadUrl = resolveDownloadUrl(item);
if (!downloadUrl) {
res.status(404).json({ error: "No package has been uploaded" });
return;
}
item.downloadCount = (item.downloadCount || 0) + 1;
await writeStore(store);
const packagePath = resolvePackagePath(item);
if (packagePath) {
if (!isPathInside(packageDir, packagePath)) {
res.status(403).json({ error: "Invalid package path" });
return;
}
await fsp.access(packagePath);
res.download(packagePath, item.originalFileName || item.fileName || path.basename(packagePath));
return;
}
res.redirect(downloadUrl);
} catch (error) {
next(error);
}
});
app.get("/api/admin/software", requireAdmin, async (req, res, next) => {
try {
const { items } = await readStore();
res.json({ items: sortSoftware(items).map((item) => publicItem(item, true)) });
} catch (error) {
next(error);
}
});
app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
try {
const bodyPatch = getBodyPatch(req.body);
if (!bodyPatch.name) {
res.status(400).json({ error: "Software name is required" });
return;
}
const store = await readStore();
const baseId = slugify(bodyPatch.name);
let id = baseId;
while (store.items.some((item) => item.id === id)) {
id = `${baseId}-${crypto.randomBytes(3).toString("hex")}`;
}
const filePatch = await makeFilePatch(req.files);
const now = new Date().toISOString();
const externalDownloadUrl = bodyPatch.externalDownloadUrl;
const item = {
id,
createdAt: now,
downloadCount: 0,
...bodyPatch,
...filePatch,
downloadUrl: filePatch.downloadUrl || externalDownloadUrl,
externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl,
};
store.items.push(item);
await writeStore(store);
res.status(201).json({ item: publicItem(item, true) });
} catch (error) {
next(error);
}
});
app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
try {
const store = await readStore();
const index = store.items.findIndex((item) => item.id === req.params.id);
if (index === -1) {
res.status(404).json({ error: "Software not found" });
return;
}
const existing = store.items[index];
const bodyPatch = getBodyPatch(req.body, existing);
if (!bodyPatch.name) {
res.status(400).json({ error: "Software name is required" });
return;
}
const filePatch = await makeFilePatch(req.files);
const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || "";
const item = {
...existing,
...bodyPatch,
...filePatch,
downloadUrl,
externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl,
};
store.items[index] = item;
await writeStore(store);
res.json({ item: publicItem(item, true) });
} catch (error) {
next(error);
}
});
app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => {
try {
const store = await readStore();
const initialLength = store.items.length;
store.items = store.items.filter((item) => item.id !== req.params.id);
if (store.items.length === initialLength) {
res.status(404).json({ error: "Software not found" });
return;
}
await writeStore(store);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
app.get("/login", (req, res) => res.redirect(301, "/login.html"));
app.get("/admin/software", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
app.get("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
app.get("/index.html", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
app.get("/software.html", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
app.get("/login.html", (req, res) => res.sendFile(path.join(projectRoot, "login.html")));
app.get("/admin/software.html", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false }));
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ error: error.message || "Internal server error" });
});
await ensureStorage();
app.listen(port, () => {
console.log(`sumi.work software center running at http://localhost:${port}`);
if (!process.env.ADMIN_TOKEN) {
console.log("ADMIN_TOKEN is not set. Using development token: sumi-admin");
}
if (!process.env.PLAYER_USERNAME || !process.env.PLAYER_PASSWORD) {
console.log("PLAYER_USERNAME/PLAYER_PASSWORD is not set. Using development player: player / sumi-player");
}
});