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"); } });