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 manifestDir = path.join(uploadDir, "manifests"); 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 }), fsp.mkdir(manifestDir, { 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"); } async function readJsonFile(filePath) { try { return parseJsonObject((await fsp.readFile(filePath, "utf8")).replace(/^\uFEFF/, ""), "version.json"); } catch (error) { if (error.status) throw error; const wrapped = new Error(`Invalid version.json: ${error.message}`); wrapped.status = 400; throw wrapped; } } function parseJsonObject(raw, label) { const parsed = JSON.parse(String(raw || "").replace(/^\uFEFF/, "")); if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { const error = new Error(`${label} must be a JSON object`); error.status = 400; throw error; } return parsed; } function parseVersionManifestContent(value) { const raw = String(value || "").trim(); if (!raw) return null; try { return parseJsonObject(raw, "versionManifestContent"); } catch (error) { if (error.status) throw error; const wrapped = new Error(`Invalid versionManifestContent: ${error.message}`); wrapped.status = 400; throw wrapped; } } 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.versionManifestStoragePath; delete copy.externalDownloadUrl; delete copy.versionManifest; copy.hasVersionManifest = Boolean(item.versionManifest); copy.versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; copy.checkUpdateUrl = `/api/software/${encodeURIComponent(item.id)}/check-update`; 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]; const versionManifestFile = files.versionManifest?.[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)}`; } if (versionManifestFile) { const versionManifest = await readJsonFile(versionManifestFile.path); patch.versionManifest = versionManifest; patch.versionManifestStoragePath = versionManifestFile.path; patch.versionManifestFileName = versionManifestFile.filename; patch.versionManifestOriginalFileName = versionManifestFile.originalname; patch.versionManifestSha256 = await hashFile(versionManifestFile.path); patch.versionManifestUploadedAt = new Date().toISOString(); } 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 || item.versionManifest?.download_url || item.versionManifest?.full_installer_url || ""; } function getLatestVersion(item) { return String(item.versionManifest?.version || item.version || "").trim(); } function normalizeVersion(value) { return String(value || "").trim().replace(/^v/i, ""); } function tokenizeVersion(value) { return normalizeVersion(value) .split(/[.+\-_]/) .filter(Boolean) .map((part) => (/^\d+$/.test(part) ? Number(part) : part.toLowerCase())); } function compareVersions(currentVersion, latestVersion) { const current = tokenizeVersion(currentVersion); const latest = tokenizeVersion(latestVersion); const length = Math.max(current.length, latest.length); for (let index = 0; index < length; index += 1) { const a = current[index] ?? 0; const b = latest[index] ?? 0; if (a === b) continue; if (typeof a === "number" && typeof b === "number") { return a < b ? -1 : 1; } return String(a).localeCompare(String(b), "en", { numeric: true }) < 0 ? -1 : 1; } return 0; } function getProtectedDownloadUrl(item) { return resolveDownloadUrl(item) ? `/api/software/${encodeURIComponent(item.id)}/download` : ""; } function getReleaseNotes(item, manifest) { return manifest.release_notes || manifest.releaseNotes || manifest.changelog || item.changelog || ""; } function createStoredVersionManifest(item, sourceManifest = {}) { const versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; const downloadUrl = getProtectedDownloadUrl(item); const releaseNotes = getReleaseNotes(item, sourceManifest); const minimumVersion = sourceManifest.min_supported_version || sourceManifest.minSupportedVersion || sourceManifest.minimumVersion || sourceManifest.minVersion || ""; const forceUpdate = Boolean(sourceManifest.forceUpdate || sourceManifest.force_update || sourceManifest.force); return { ...sourceManifest, version: String(sourceManifest.version || item.version || "1.0.0").trim(), update_url: sourceManifest.update_url || versionManifestUrl, updateUrl: sourceManifest.updateUrl || sourceManifest.update_url || versionManifestUrl, release_notes: releaseNotes, releaseNotes, changelog: sourceManifest.changelog || releaseNotes, download_url: sourceManifest.download_url || downloadUrl, downloadUrl: sourceManifest.downloadUrl || sourceManifest.download_url || downloadUrl, full_installer_url: sourceManifest.full_installer_url || sourceManifest.download_url || downloadUrl, fullInstallerUrl: sourceManifest.fullInstallerUrl || sourceManifest.full_installer_url || sourceManifest.downloadUrl || sourceManifest.download_url || downloadUrl, forceUpdate, force_update: forceUpdate, minSupportedVersion: sourceManifest.minSupportedVersion || sourceManifest.min_supported_version || minimumVersion, min_supported_version: sourceManifest.min_supported_version || sourceManifest.minSupportedVersion || minimumVersion, deltaUpdates: sourceManifest.deltaUpdates || sourceManifest.delta_updates || {}, delta_updates: sourceManifest.delta_updates || sourceManifest.deltaUpdates || {}, }; } function applyVersionManifest(item, sourceManifest, mode = "auto") { const manifest = createStoredVersionManifest(item, sourceManifest || {}); item.versionManifest = manifest; item.version = String(manifest.version || item.version || "1.0.0").trim(); item.versionManifestUploadedAt = new Date().toISOString(); item.versionManifestSource = mode; item.versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; item.checkUpdateUrl = `/api/software/${encodeURIComponent(item.id)}/check-update`; return item; } function buildVersionManifest(item) { const sourceManifest = item.versionManifest && typeof item.versionManifest === "object" ? item.versionManifest : {}; const latestVersion = getLatestVersion(item); const downloadUrl = getProtectedDownloadUrl(item); const versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; const releaseNotes = getReleaseNotes(item, sourceManifest); const minimumVersion = sourceManifest.min_supported_version || sourceManifest.minSupportedVersion || sourceManifest.minimumVersion || sourceManifest.minVersion || ""; return { ...sourceManifest, id: item.id, name: item.name, version: latestVersion, updatedAt: item.updatedAt, changelog: releaseNotes, releaseNotes, release_notes: releaseNotes, size: sourceManifest.size || item.size || "", sizeBytes: sourceManifest.sizeBytes ?? item.sizeBytes ?? 0, sha256: sourceManifest.sha256 || item.sha256 || "", updateUrl: versionManifestUrl, update_url: versionManifestUrl, downloadUrl, download_url: downloadUrl, fullInstallerUrl: downloadUrl, full_installer_url: downloadUrl, forceUpdate: Boolean(sourceManifest.forceUpdate || sourceManifest.force_update || sourceManifest.force), force_update: Boolean(sourceManifest.forceUpdate || sourceManifest.force_update || sourceManifest.force), minSupportedVersion: minimumVersion, min_supported_version: minimumVersion, deltaUpdates: sourceManifest.deltaUpdates || sourceManifest.delta_updates || {}, delta_updates: sourceManifest.delta_updates || sourceManifest.deltaUpdates || {}, checkUpdateUrl: `/api/software/${encodeURIComponent(item.id)}/check-update`, versionManifestUrl, }; } function buildUpdateCheck(item, currentVersion) { const manifest = buildVersionManifest(item); const latestVersion = manifest.version; const comparison = compareVersions(currentVersion, latestVersion); const minimumVersion = manifest.min_supported_version || manifest.minSupportedVersion || manifest.minimumVersion || manifest.minVersion || ""; const belowMinimum = minimumVersion ? compareVersions(currentVersion, minimumVersion) < 0 : false; const updateAvailable = comparison < 0; const forceUpdate = Boolean(manifest.forceUpdate || manifest.force_update || manifest.force || belowMinimum); return { id: item.id, name: item.name, currentVersion, latestVersion, updateAvailable, comparison, forceUpdate, minSupportedVersion: minimumVersion, min_supported_version: minimumVersion, belowMinimum, updatedAt: item.updatedAt, changelog: manifest.changelog || "", releaseNotes: manifest.releaseNotes || manifest.release_notes || "", release_notes: manifest.release_notes || manifest.releaseNotes || "", size: manifest.size || "", sizeBytes: manifest.sizeBytes || 0, sha256: manifest.sha256 || "", downloadUrl: manifest.downloadUrl, download_url: manifest.download_url, fullInstallerUrl: manifest.fullInstallerUrl, full_installer_url: manifest.full_installer_url, updateUrl: manifest.updateUrl, update_url: manifest.update_url, deltaUpdates: manifest.deltaUpdates || {}, delta_updates: manifest.delta_updates || {}, versionManifestUrl: manifest.versionManifestUrl, manifest, }; } 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 ""; } async function cleanupUploadedFiles(req) { const uploadedFiles = Object.values(req.files || {}).flat(); for (const file of uploadedFiles) { if (!file?.path) continue; const filePath = path.resolve(file.path); if (isPathInside(uploadDir, filePath)) { await fsp.rm(filePath, { force: true }).catch(() => {}); } } } const storage = multer.diskStorage({ destination(req, file, callback) { if (file.fieldname === "icon") { callback(null, iconDir); return; } if (file.fieldname === "versionManifest") { callback(null, manifestDir); return; } callback(null, 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: 3, }, fileFilter(req, file, callback) { if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) { const error = new Error("Icon must be an image file"); error.status = 400; callback(error); return; } if (file.fieldname === "versionManifest") { const isJson = file.mimetype === "application/json" || file.originalname.toLowerCase().endsWith(".json"); if (!isJson) { const error = new Error("version.json must be a JSON file"); error.status = 400; callback(error); return; } callback(null, true); 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; } const manifest = buildVersionManifest(item); res.json({ id: item.id, name: item.name, version: manifest.version, changelog: manifest.changelog, releaseNotes: manifest.releaseNotes, release_notes: manifest.release_notes, updatedAt: item.updatedAt, size: manifest.size, sizeBytes: manifest.sizeBytes, sha256: manifest.sha256, downloadUrl: manifest.downloadUrl, download_url: manifest.download_url, fullInstallerUrl: manifest.fullInstallerUrl, full_installer_url: manifest.full_installer_url, updateUrl: manifest.updateUrl, update_url: manifest.update_url, versionManifestUrl: manifest.versionManifestUrl, checkUpdateUrl: manifest.checkUpdateUrl, }); } catch (error) { next(error); } }); app.get("/api/software/:id/version.json", 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(buildVersionManifest(item)); } catch (error) { next(error); } }); app.get("/api/software/:id/check-update", async (req, res, next) => { try { const currentVersion = String(req.query.version || req.query.currentVersion || "").trim(); if (!currentVersion) { res.status(400).json({ error: "Current version is required. Use ?version=1.0.0" }); return; } 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(buildUpdateCheck(item, currentVersion)); } catch (error) { next(error); } }); app.post("/api/software/:id/check-update", async (req, res, next) => { try { const currentVersion = String(req.body.version || req.body.currentVersion || "").trim(); if (!currentVersion) { res.status(400).json({ error: "Current version is required" }); return; } 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(buildUpdateCheck(item, currentVersion)); } 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", requirePlayer, 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", requirePlayer, requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }, { name: "versionManifest", 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 contentManifest = parseVersionManifestContent(req.body.versionManifestContent); const explicitManifest = filePatch.versionManifest || contentManifest; if (explicitManifest?.version) { bodyPatch.version = String(explicitManifest.version).trim(); } 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, }; if (explicitManifest) { applyVersionManifest(item, explicitManifest, filePatch.versionManifest ? "file" : "manual"); } else if (resolveDownloadUrl(item)) { applyVersionManifest(item, {}, "auto"); } else { item.versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; item.checkUpdateUrl = `/api/software/${encodeURIComponent(item.id)}/check-update`; } 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", requirePlayer, requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }, { name: "versionManifest", 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 contentManifest = parseVersionManifestContent(req.body.versionManifestContent); const explicitManifest = filePatch.versionManifest || contentManifest; if (explicitManifest?.version) { bodyPatch.version = String(explicitManifest.version).trim(); } const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || ""; const item = { ...existing, ...bodyPatch, ...filePatch, downloadUrl, externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl, }; if (explicitManifest) { applyVersionManifest(item, explicitManifest, filePatch.versionManifest ? "file" : "manual"); } else if (filePatch.downloadUrl || bodyPatch.externalDownloadUrl || !item.versionManifest) { const autoSource = item.versionManifest ? { delta_updates: item.versionManifest.delta_updates || item.versionManifest.deltaUpdates || {} } : {}; applyVersionManifest(item, autoSource, "auto"); } else { item.versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; item.checkUpdateUrl = `/api/software/${encodeURIComponent(item.id)}/check-update`; } store.items[index] = item; await writeStore(store); res.json({ item: publicItem(item, true) }); } catch (error) { next(error); } }); app.delete("/api/admin/software/:id", requirePlayer, 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", requirePlayer, (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", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html"))); app.use("/assets", express.static(path.join(projectRoot, "assets"), { fallthrough: false })); app.use(async (error, req, res, next) => { await cleanupUploadedFiles(req); console.error(error); res.status(error.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"); } });