feat: add software download center

This commit is contained in:
lwt
2026-06-08 20:53:14 +08:00
parent aa29e4e462
commit c8cfe68361
18 changed files with 3738 additions and 0 deletions
+617
View File
@@ -0,0 +1,617 @@
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");
}
});