Files
sumi.work/server/server.js
T
2026-06-09 15:01:28 +08:00

1176 lines
42 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 storageRoot = process.env.SUMI_STORAGE_DIR ? path.resolve(process.env.SUMI_STORAGE_DIR) : "";
const dataDir = path.resolve(process.env.SUMI_DATA_DIR || (storageRoot ? path.join(storageRoot, "data") : path.join(__dirname, "data")));
const uploadDir = path.resolve(process.env.SUMI_UPLOAD_DIR || (storageRoot ? path.join(storageRoot, "uploads") : 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 getPublicOrigin(req) {
const forwardedProto = String(req.get("x-forwarded-proto") || "").split(",")[0].trim();
const proto = forwardedProto || req.protocol || "http";
const forwardedHost = String(req.get("x-forwarded-host") || "").split(",")[0].trim();
const host = forwardedHost || req.get("host") || "";
return host ? `${proto}://${host}` : "";
}
function absoluteUrl(req, value) {
const url = String(value || "").trim();
if (!url || /^https?:\/\//i.test(url)) return url;
if (!url.startsWith("/")) return url;
const origin = getPublicOrigin(req);
return origin ? `${origin}${url}` : url;
}
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.deltaPackages;
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)}/update-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;
}
async function makeDeltaPackage(file, fromVersion, toVersion) {
const sourceVersion = normalizeVersion(fromVersion);
const targetVersion = normalizeVersion(toVersion);
if (!file) return null;
if (!sourceVersion) {
const error = new Error("Delta package source version is required");
error.status = 400;
throw error;
}
return {
fromVersion: sourceVersion,
toVersion: targetVersion,
storagePath: file.path,
originalFileName: file.originalname,
fileName: file.filename,
sizeBytes: file.size,
size: formatSize(file.size),
sha256: await hashFile(file.path),
uploadedAt: new Date().toISOString(),
type: "file-zip",
};
}
function getManifestDeltaMap(manifest = {}) {
const map = manifest.delta_updates || manifest.deltaUpdates || manifest.delta || {};
return map && typeof map === "object" && !Array.isArray(map) ? map : {};
}
function inferDeltaFromVersion(manifest = {}) {
const keys = Object.keys(getManifestDeltaMap(manifest));
return keys.length === 1 ? keys[0] : "";
}
function mergeDeltaPackageIntoManifest(sourceManifest = {}, deltaPackage, itemId) {
sourceManifest = sourceManifest && typeof sourceManifest === "object" ? sourceManifest : {};
if (!deltaPackage) return sourceManifest;
const fromVersion = deltaPackage.fromVersion;
const deltaUrl = `/api/software/${encodeURIComponent(itemId)}/delta-download?from=${encodeURIComponent(fromVersion)}`;
const next = {
...sourceManifest,
delta_updates: {
...getManifestDeltaMap(sourceManifest),
[fromVersion]: {
...(getManifestDeltaMap(sourceManifest)[fromVersion] || {}),
type: "file-zip",
format: "server-manager-file-delta-v1",
fromVersion,
from_version: fromVersion,
targetVersion: deltaPackage.toVersion || sourceManifest.version || "",
target_version: deltaPackage.toVersion || sourceManifest.version || "",
package_url: deltaUrl,
packageUrl: deltaUrl,
patch_url: deltaUrl,
patchUrl: deltaUrl,
sha256: deltaPackage.sha256,
sizeBytes: deltaPackage.sizeBytes,
size: deltaPackage.size,
},
},
};
next.deltaUpdates = next.delta_updates;
return next;
}
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 getUpdateDownloadUrl(item) {
return resolveDownloadUrl(item) ? `/api/software/${encodeURIComponent(item.id)}/update-download` : "";
}
function getDeltaDownloadUrl(item, fromVersion) {
const deltaPackage = item.deltaPackages?.[normalizeVersion(fromVersion)];
return deltaPackage ? `/api/software/${encodeURIComponent(item.id)}/delta-download?from=${encodeURIComponent(normalizeVersion(fromVersion))}` : "";
}
function getReleaseNotes(item, manifest) {
return manifest.release_notes || manifest.releaseNotes || manifest.changelog || item.changelog || "";
}
function buildPublicDeltaUpdates(item, req, sourceManifest = {}) {
const manifestDeltaMap = getManifestDeltaMap(sourceManifest);
const result = {};
const allFromVersions = new Set([
...Object.keys(manifestDeltaMap),
...Object.keys(item.deltaPackages || {}),
]);
for (const fromVersion of allFromVersions) {
const normalizedFrom = normalizeVersion(fromVersion);
if (!normalizedFrom) continue;
const sourceEntry = manifestDeltaMap[fromVersion] || manifestDeltaMap[normalizedFrom] || {};
if (!sourceEntry || typeof sourceEntry !== "object" || Array.isArray(sourceEntry)) continue;
const deltaPackage = item.deltaPackages?.[normalizedFrom];
if (deltaPackage) {
const deltaUrl = absoluteUrl(req, getDeltaDownloadUrl(item, normalizedFrom));
result[normalizedFrom] = {
...sourceEntry,
type: sourceEntry.type || deltaPackage.type || "file-zip",
format: sourceEntry.format || "server-manager-file-delta-v1",
fromVersion: sourceEntry.fromVersion || sourceEntry.from_version || normalizedFrom,
from_version: sourceEntry.from_version || sourceEntry.fromVersion || normalizedFrom,
targetVersion: sourceEntry.targetVersion || sourceEntry.target_version || deltaPackage.toVersion || getLatestVersion(item),
target_version: sourceEntry.target_version || sourceEntry.targetVersion || deltaPackage.toVersion || getLatestVersion(item),
packageUrl: deltaUrl,
package_url: deltaUrl,
patchUrl: deltaUrl,
patch_url: deltaUrl,
sha256: sourceEntry.sha256 || deltaPackage.sha256,
sizeBytes: sourceEntry.sizeBytes ?? deltaPackage.sizeBytes,
size: sourceEntry.size || deltaPackage.size,
};
continue;
}
result[normalizedFrom] = { ...sourceEntry };
}
return result;
}
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, req) {
const sourceManifest = item.versionManifest && typeof item.versionManifest === "object" ? item.versionManifest : {};
const latestVersion = getLatestVersion(item);
const downloadUrl = absoluteUrl(req, getUpdateDownloadUrl(item));
const versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`;
const updateUrl = absoluteUrl(req, versionManifestUrl);
const releaseNotes = getReleaseNotes(item, sourceManifest);
const minimumVersion = sourceManifest.min_supported_version || sourceManifest.minSupportedVersion || sourceManifest.minimumVersion || sourceManifest.minVersion || "";
const deltaUpdates = buildPublicDeltaUpdates(item, req, sourceManifest);
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,
update_url: updateUrl,
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,
delta_updates: deltaUpdates,
checkUpdateUrl: absoluteUrl(req, `/api/software/${encodeURIComponent(item.id)}/check-update`),
versionManifestUrl: updateUrl,
};
}
function buildUpdateCheck(item, currentVersion, req) {
const manifest = buildVersionManifest(item, req);
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 resolveUploadPathFromUrl(publicUrl, prefix, directory) {
if (!publicUrl?.startsWith(prefix)) return "";
try {
return path.resolve(directory, path.basename(decodeURIComponent(publicUrl)));
} catch {
return "";
}
}
function resolvePackagePath(item) {
const publicPackagePath = resolveUploadPathFromUrl(item.downloadUrl, "/uploads/packages/", packageDir);
if (item.storagePath) {
const storagePath = path.resolve(item.storagePath);
if (isPathInside(packageDir, storagePath)) return storagePath;
if (publicPackagePath) return publicPackagePath;
return storagePath;
}
return publicPackagePath;
}
function resolveDeltaPackagePath(item, fromVersion) {
const deltaPackage = item.deltaPackages?.[normalizeVersion(fromVersion)];
if (!deltaPackage?.storagePath) return "";
return path.resolve(deltaPackage.storagePath);
}
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: 4,
},
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, req);
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, req));
} 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, req));
} 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, req));
} catch (error) {
next(error);
}
});
async function sendSoftwareDownload(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);
}
}
async function sendSoftwareDeltaDownload(req, res, next) {
try {
const fromVersion = normalizeVersion(req.query.from || req.query.version || req.query.currentVersion || "");
if (!fromVersion) {
res.status(400).json({ error: "Delta source version is required. Use ?from=1.0.0" });
return;
}
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 deltaPackage = item.deltaPackages?.[fromVersion];
const deltaPath = resolveDeltaPackagePath(item, fromVersion);
if (!deltaPackage || !deltaPath) {
res.status(404).json({ error: "Delta package not found" });
return;
}
if (!isPathInside(packageDir, deltaPath)) {
res.status(403).json({ error: "Invalid delta package path" });
return;
}
await fsp.access(deltaPath);
item.deltaDownloadCount = (item.deltaDownloadCount || 0) + 1;
await writeStore(store);
res.download(deltaPath, deltaPackage.originalFileName || deltaPackage.fileName || path.basename(deltaPath));
} catch (error) {
next(error);
}
}
app.get("/api/software/:id/update-download", sendSoftwareDownload);
app.get("/api/software/:id/delta-download", sendSoftwareDeltaDownload);
app.get("/api/software/:id/download", requirePlayer, sendSoftwareDownload);
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);
}
});
const softwareUploadFields = [
{ name: "package", maxCount: 1 },
{ name: "deltaPackage", maxCount: 1 },
{ name: "icon", maxCount: 1 },
{ name: "versionManifest", maxCount: 1 },
];
app.post("/api/admin/software", requirePlayer, requireAdmin, upload.fields(softwareUploadFields), 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);
let explicitManifest = filePatch.versionManifest || contentManifest;
if (explicitManifest?.version) {
bodyPatch.version = String(explicitManifest.version).trim();
}
const deltaPackage = await makeDeltaPackage(
req.files?.deltaPackage?.[0],
req.body.deltaFromVersion || inferDeltaFromVersion(explicitManifest || {}),
explicitManifest?.version || bodyPatch.version,
);
const now = new Date().toISOString();
const externalDownloadUrl = bodyPatch.externalDownloadUrl;
const item = {
id,
createdAt: now,
downloadCount: 0,
...bodyPatch,
...filePatch,
deltaPackages: deltaPackage ? { [deltaPackage.fromVersion]: deltaPackage } : {},
downloadUrl: filePatch.downloadUrl || externalDownloadUrl,
externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl,
};
if (deltaPackage) {
explicitManifest = mergeDeltaPackageIntoManifest(explicitManifest || {}, deltaPackage, item.id);
}
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(softwareUploadFields), 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);
let explicitManifest = filePatch.versionManifest || contentManifest;
if (explicitManifest?.version) {
bodyPatch.version = String(explicitManifest.version).trim();
}
const deltaPackage = await makeDeltaPackage(
req.files?.deltaPackage?.[0],
req.body.deltaFromVersion || inferDeltaFromVersion(explicitManifest || {}),
explicitManifest?.version || bodyPatch.version || existing.version,
);
const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || "";
const item = {
...existing,
...bodyPatch,
...filePatch,
deltaPackages: {
...(existing.deltaPackages || {}),
...(deltaPackage ? { [deltaPackage.fromVersion]: deltaPackage } : {}),
},
downloadUrl,
externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl,
};
if (deltaPackage) {
explicitManifest = mergeDeltaPackageIntoManifest(explicitManifest || {}, deltaPackage, item.id);
}
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}`);
console.log(`data directory: ${dataDir}`);
console.log(`upload directory: ${uploadDir}`);
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");
}
});