diff --git a/.gitignore b/.gitignore
index 78b8f96..90343a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,10 @@
node_modules/
server/uploads/packages/*
server/uploads/icons/*
+server/uploads/manifests/*
!server/uploads/packages/.gitkeep
!server/uploads/icons/.gitkeep
+!server/uploads/manifests/.gitkeep
.env
npm-debug.log*
server-test.*.log
diff --git a/DEPLOY.md b/DEPLOY.md
index e0b741a..d31bd81 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -275,7 +275,100 @@ curl -i -b /tmp/sumi-player.cookie http://127.0.0.1:3030/api/auth/me
curl -i -H "X-Admin-Token: change-this-admin-token" http://127.0.0.1:3030/api/admin/software
```
-## 10. 数据与备份
+## 10. version.json 与检查更新
+
+管理后台上传软件时可以同时上传 `version.json`,也可以直接编辑“版本内容 JSON”。如果上传安装包且版本内容留空,服务端会自动生成一个兼容更新器的 version.json。
+
+推荐 `version.json`:
+
+```json
+{
+ "version": "1.2.0",
+ "changelog": "修复已知问题并优化启动速度",
+ "forceUpdate": false,
+ "minSupportedVersion": "1.0.0",
+ "sha256": "package-sha256-if-known"
+}
+```
+
+也兼容下面这种更新器常用格式:
+
+```json
+{
+ "version": "1.1.5",
+ "update_url": "https://sumi.work/api/software/server-manager/version.json",
+ "release_notes": "数据库测试连接改为真实 MySQL 连接探测。",
+ "download_url": "https://sumi.work/api/software/server-manager/download",
+ "full_installer_url": "https://sumi.work/api/software/server-manager/download",
+ "delta_updates": {}
+}
+```
+
+字段说明:
+
+- `version`:最新版本号,检查更新时用于和客户端当前版本对比
+- `changelog`:更新说明
+- `release_notes`:更新说明,等同于 `changelog`
+- `update_url`:version.json 地址,建议改成当前下载中心的 `/api/software/<软件ID>/version.json`
+- `download_url`:完整安装包下载地址,建议改成当前下载中心的 `/api/software/<软件ID>/download`
+- `full_installer_url`:完整安装包下载地址,等同于 `download_url`
+- `delta_updates`:差分更新信息,会原样返回
+- `forceUpdate`:是否强制更新
+- `minSupportedVersion`:最低支持版本,客户端版本低于它时会返回 `forceUpdate: true`
+- `sha256`:安装包校验值;如果未提供,会使用服务端上传安装包计算出的值
+
+如果使用你原来的内网文件服务地址,例如:
+
+```json
+"update_url": "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json",
+"download_url": "http://172.18.180.94:3000/api/download?path=server_manager/build/output/ServerManager_Setup.exe"
+```
+
+它也能被服务端读取和保存。但如果希望下载中心继续执行“玩家登录后才能下载”的规则,应改成下载中心自己的地址:
+
+```json
+"update_url": "https://sumi.work/api/software/server-manager/version.json",
+"download_url": "https://sumi.work/api/software/server-manager/download",
+"full_installer_url": "https://sumi.work/api/software/server-manager/download"
+```
+
+其中 `server-manager` 是软件 ID。新增软件时服务端会按软件名自动生成 ID;也可以上传后在管理接口返回结果里查看 `id`、`versionManifestUrl` 和 `downloadUrl`。
+
+后台操作规则:
+
+- 只上传安装包,版本内容留空:服务端自动生成 version.json。
+- 上传安装包,同时填写版本内容 JSON:安装包会更新,版本内容按填写的 JSON 保存。
+- 不上传安装包,只修改版本内容 JSON:只更新版本清单和检查更新内容。
+- 上传 `version.json` 文件:文件内容优先于文本框内容。
+
+获取版本清单:
+
+```bash
+curl -i http://127.0.0.1:3030/api/software/sumi-launcher/version.json
+```
+
+检查更新:
+
+```bash
+curl -i "http://127.0.0.1:3030/api/software/sumi-launcher/check-update?version=1.0.0"
+```
+
+返回重点字段:
+
+```json
+{
+ "currentVersion": "1.0.0",
+ "latestVersion": "1.2.0",
+ "updateAvailable": true,
+ "forceUpdate": false,
+ "downloadUrl": "/api/software/sumi-launcher/download",
+ "versionManifestUrl": "/api/software/sumi-launcher/version.json"
+}
+```
+
+`downloadUrl` 仍然受玩家登录保护,未登录下载会返回 `401` 或跳转到登录页。
+
+## 11. 数据与备份
需要持久化和备份:
@@ -283,6 +376,7 @@ curl -i -H "X-Admin-Token: change-this-admin-token" http://127.0.0.1:3030/api/ad
- `server/data/players.json`:玩家账号数据
- `server/uploads/icons/`:上传的软件图标
- `server/uploads/packages/`:上传的软件安装包
+- `server/uploads/manifests/`:上传的 version.json 文件
建议备份命令:
@@ -292,7 +386,7 @@ tar -czf sumi-work-data-$(date +%F).tar.gz server/data server/uploads
恢复时把这两个目录放回仓库根目录,然后重启服务。
-## 11. 常见问题
+## 12. 常见问题
端口被占用:
@@ -324,7 +418,7 @@ lsof -i :3030
- 不要配置 Nginx 直接静态暴露 `server/uploads/packages/`
- 正确做法是所有请求都反代到 Node 服务
-## 12. 快速执行清单
+## 13. 快速执行清单
```bash
cd /path/to/sumi.work
diff --git a/admin/software.html b/admin/software.html
index ff821cd..eb2306c 100644
--- a/admin/software.html
+++ b/admin/software.html
@@ -99,12 +99,25 @@
+
上传安装包且版本内容留空时会自动生成 version.json;需要手动调整 release_notes、delta_updates 等内容时,直接编辑版本内容 JSON 后保存。
diff --git a/assets/js/software-admin.js b/assets/js/software-admin.js
index e1365ba..54e6efd 100644
--- a/assets/js/software-admin.js
+++ b/assets/js/software-admin.js
@@ -82,6 +82,7 @@ function renderList() {
${item.active === false ? "已隐藏" : "前台显示"}
${item.featured ? "精选" : "普通"}
+ ${item.versionManifest ? "version.json" : "无版本清单"}
${escapeHtml(item.size || "未知大小")}
${formatDate(item.updatedAt)}
@@ -128,6 +129,7 @@ function resetForm() {
els.form.elements.publisher.value = "sumi.work";
els.form.elements.sort.value = "100";
els.form.elements.active.checked = true;
+ els.form.elements.versionManifestContent.value = "";
els.formTitle.textContent = "新增软件";
els.submitButton.textContent = "保存软件";
setStatus(els.formStatus, "");
@@ -150,9 +152,11 @@ function fillForm(item) {
els.form.elements.featured.checked = Boolean(item.featured);
els.form.elements.icon.value = "";
els.form.elements.package.value = "";
+ els.form.elements.versionManifest.value = "";
+ els.form.elements.versionManifestContent.value = item.versionManifest ? JSON.stringify(item.versionManifest, null, 2) : "";
els.formTitle.textContent = `更新 ${item.name}`;
els.submitButton.textContent = "更新软件";
- setStatus(els.formStatus, "已载入当前软件,选择新安装包即可发布新版本。", "ok");
+ setStatus(els.formStatus, item.versionManifest ? "已载入当前软件,可替换安装包或直接编辑版本内容 JSON。" : "已载入当前软件,上传安装包且版本内容留空时会自动生成 version.json。", "ok");
window.scrollTo({ top: 0, behavior: "smooth" });
}
diff --git a/assets/js/software.js b/assets/js/software.js
index 3b3efbc..d53d584 100644
--- a/assets/js/software.js
+++ b/assets/js/software.js
@@ -79,6 +79,7 @@ const els = {
topicDev: document.querySelector("#topic-dev"),
topicSecurity: document.querySelector("#topic-security"),
playerStatus: document.querySelector("#player-status"),
+ authLinks: [...document.querySelectorAll("[data-player-auth]")],
};
function escapeHtml(value = "") {
@@ -114,6 +115,10 @@ function getDownloadHref(item) {
return `/api/software/${encodeURIComponent(item.id)}/download`;
}
+function getLoginHref(next = "/software.html") {
+ return `login.html?next=${encodeURIComponent(next)}`;
+}
+
function iconMarkup(item) {
const safeName = escapeHtml(item.name);
if (item.iconUrl) {
@@ -281,6 +286,16 @@ async function loadAuth() {
state.authLoaded = true;
render();
}
+ return state.player;
+}
+
+async function ensurePlayer(next) {
+ if (!state.authLoaded) {
+ await loadAuth();
+ }
+ if (state.player) return true;
+ window.location.href = getLoginHref(next);
+ return false;
}
async function loadSoftware() {
@@ -309,6 +324,23 @@ els.categoryButtons.forEach((button) => {
});
els.refreshButton.addEventListener("click", loadSoftware);
+els.authLinks.forEach((link) => {
+ link.addEventListener("click", async (event) => {
+ const href = link.getAttribute("href") || "/software.html";
+ const next = link.dataset.next || href;
+ if (state.authLoaded && state.player) return;
+
+ event.preventDefault();
+ const isAuthenticated = await ensurePlayer(next);
+ if (!isAuthenticated) return;
+
+ if (href.startsWith("#")) {
+ document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
+ return;
+ }
+ window.location.href = href;
+ });
+});
els.playerStatus.addEventListener("click", async (event) => {
if (!event.target.closest("#player-logout")) return;
await fetch("/api/auth/logout", { method: "POST", headers: { Accept: "application/json" } });
diff --git a/server/server.js b/server/server.js
index 9645fe4..d2b58a0 100644
--- a/server/server.js
+++ b/server/server.js
@@ -13,6 +13,7 @@ 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);
@@ -67,6 +68,7 @@ async function ensureStorage() {
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);
@@ -153,6 +155,40 @@ async function hashFile(filePath) {
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}`;
@@ -254,7 +290,12 @@ function publicItem(item, includePrivate = false) {
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`;
}
@@ -274,6 +315,7 @@ 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;
@@ -292,6 +334,16 @@ async function makeFilePatch(files = {}) {
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;
}
@@ -316,7 +368,165 @@ function getBodyPatch(body, existing = {}) {
}
function resolveDownloadUrl(item) {
- return item.downloadUrl || item.externalDownloadUrl || "";
+ 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) {
@@ -332,9 +542,28 @@ function resolvePackagePath(item) {
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) {
- callback(null, file.fieldname === "icon" ? iconDir : packageDir);
+ 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);
@@ -348,11 +577,24 @@ const upload = multer({
storage,
limits: {
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024),
- files: 2,
+ files: 3,
},
fileFilter(req, file, callback) {
if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) {
- callback(new Error("Icon must be an image file"));
+ 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);
@@ -442,22 +684,86 @@ app.get("/api/software/:id/latest", async (req, res, next) => {
res.status(404).json({ error: "Software not found" });
return;
}
+ const manifest = buildVersionManifest(item);
res.json({
id: item.id,
name: item.name,
- version: item.version,
- changelog: item.changelog,
+ version: manifest.version,
+ changelog: manifest.changelog,
+ releaseNotes: manifest.releaseNotes,
+ release_notes: manifest.release_notes,
updatedAt: item.updatedAt,
- size: item.size,
- sizeBytes: item.sizeBytes,
- sha256: item.sha256,
- downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`,
+ 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();
@@ -493,7 +799,7 @@ app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => {
}
});
-app.get("/api/admin/software", requireAdmin, async (req, res, next) => {
+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)) });
@@ -502,7 +808,7 @@ app.get("/api/admin/software", requireAdmin, async (req, res, next) => {
}
});
-app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
+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) {
@@ -518,6 +824,11 @@ app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package",
}
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 = {
@@ -530,6 +841,14 @@ app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package",
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) });
@@ -538,7 +857,7 @@ app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package",
}
});
-app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package", maxCount: 1 }, { name: "icon", maxCount: 1 }]), async (req, res, next) => {
+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);
@@ -555,6 +874,11 @@ app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package
}
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,
@@ -563,6 +887,17 @@ app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package
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);
@@ -572,7 +907,7 @@ app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package
}
});
-app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => {
+app.delete("/api/admin/software/:id", requirePlayer, requireAdmin, async (req, res, next) => {
try {
const store = await readStore();
const initialLength = store.items.length;
@@ -591,17 +926,18 @@ app.delete("/api/admin/software/:id", requireAdmin, async (req, res, next) => {
app.get("/software", (req, res) => res.redirect(301, "/software.html"));
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "software.html")));
app.get("/login", (req, res) => res.redirect(301, "/login.html"));
-app.get("/admin/software", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.html")));
+app.get("/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", (req, res) => res.sendFile(path.join(projectRoot, "admin", "software.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((error, req, res, next) => {
+app.use(async (error, req, res, next) => {
+ await cleanupUploadedFiles(req);
console.error(error);
- res.status(500).json({ error: error.message || "Internal server error" });
+ res.status(error.status || 500).json({ error: error.message || "Internal server error" });
});
await ensureStorage();
diff --git a/server/uploads/manifests/.gitkeep b/server/uploads/manifests/.gitkeep
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/server/uploads/manifests/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/software.html b/software.html
index 6130e4c..89cb712 100644
--- a/software.html
+++ b/software.html
@@ -18,7 +18,7 @@