1188 lines
43 KiB
JavaScript
1188 lines
43 KiB
JavaScript
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";
|
||
|
||
import { ensureSvnStorage, registerSvnRoutes } from "./svn-service.js";
|
||
|
||
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 svnRoot = path.resolve(process.env.SUMI_SVN_DIR || (storageRoot ? path.join(storageRoot, "svn") : path.join(__dirname, "svn")));
|
||
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 svnDataFile = path.join(dataDir, "svn.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 }),
|
||
fsp.mkdir(svnRoot, { 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();
|
||
}
|
||
|
||
await ensureSvnStorage({ svnRoot, svnDataFile });
|
||
}
|
||
|
||
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)}/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);
|
||
}
|
||
});
|
||
|
||
registerSvnRoutes(app, { svnRoot, svnDataFile, requirePlayer, requireAdmin });
|
||
|
||
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("/admin/svn", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "svn.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.get("/admin/svn.html", requirePlayer, (req, res) => res.sendFile(path.join(projectRoot, "admin", "svn.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}`);
|
||
console.log(`svn directory: ${svnRoot}`);
|
||
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");
|
||
}
|
||
});
|