Add software version manifest management

This commit is contained in:
lwt
2026-06-08 22:02:51 +08:00
parent 228d625e16
commit b272026ac9
8 changed files with 508 additions and 26 deletions
+2
View File
@@ -1,8 +1,10 @@
node_modules/ node_modules/
server/uploads/packages/* server/uploads/packages/*
server/uploads/icons/* server/uploads/icons/*
server/uploads/manifests/*
!server/uploads/packages/.gitkeep !server/uploads/packages/.gitkeep
!server/uploads/icons/.gitkeep !server/uploads/icons/.gitkeep
!server/uploads/manifests/.gitkeep
.env .env
npm-debug.log* npm-debug.log*
server-test.*.log server-test.*.log
+97 -3
View File
@@ -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 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/data/players.json`:玩家账号数据
- `server/uploads/icons/`:上传的软件图标 - `server/uploads/icons/`:上传的软件图标
- `server/uploads/packages/`:上传的软件安装包 - `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/` - 不要配置 Nginx 直接静态暴露 `server/uploads/packages/`
- 正确做法是所有请求都反代到 Node 服务 - 正确做法是所有请求都反代到 Node 服务
## 12. 快速执行清单 ## 13. 快速执行清单
```bash ```bash
cd /path/to/sumi.work cd /path/to/sumi.work
+14 -1
View File
@@ -99,12 +99,25 @@
<label for="package">安装包</label> <label for="package">安装包</label>
<input id="package" name="package" type="file" /> <input id="package" name="package" type="file" />
</div> </div>
<div class="field full">
<label for="version-manifest">版本清单 version.json</label>
<input id="version-manifest" name="versionManifest" type="file" accept="application/json,.json" />
</div>
<div class="field full">
<label for="version-manifest-content">版本内容 JSON</label>
<textarea
id="version-manifest-content"
name="versionManifestContent"
maxlength="8000"
placeholder='留空时,上传安装包后服务端会自动生成 {"version":"1.0.0","update_url":"...","download_url":"..."}'
></textarea>
</div>
</div> </div>
<div class="checkbox-row"> <div class="checkbox-row">
<label class="checkbox-field"><input name="active" type="checkbox" checked /> 前台显示</label> <label class="checkbox-field"><input name="active" type="checkbox" checked /> 前台显示</label>
<label class="checkbox-field"><input name="featured" type="checkbox" /> 精选推荐</label> <label class="checkbox-field"><input name="featured" type="checkbox" /> 精选推荐</label>
</div> </div>
<p class="form-hint">更新软件时只选择新的安装包即可替换下载文件;不选择文件会保留原安装包</p> <p class="form-hint">上传安装包且版本内容留空时会自动生成 version.json;需要手动调整 release_notes、delta_updates 等内容时,直接编辑版本内容 JSON 后保存</p>
<div class="form-actions"> <div class="form-actions">
<button class="primary-action" type="submit" id="submit-button">保存软件</button> <button class="primary-action" type="submit" id="submit-button">保存软件</button>
<button class="secondary-action" type="button" id="reset-form">清空</button> <button class="secondary-action" type="button" id="reset-form">清空</button>
+5 -1
View File
@@ -82,6 +82,7 @@ function renderList() {
<div class="app-meta"> <div class="app-meta">
<span>${item.active === false ? "已隐藏" : "前台显示"}</span> <span>${item.active === false ? "已隐藏" : "前台显示"}</span>
<span>${item.featured ? "精选" : "普通"}</span> <span>${item.featured ? "精选" : "普通"}</span>
<span>${item.versionManifest ? "version.json" : "无版本清单"}</span>
<span>${escapeHtml(item.size || "未知大小")}</span> <span>${escapeHtml(item.size || "未知大小")}</span>
<span>${formatDate(item.updatedAt)}</span> <span>${formatDate(item.updatedAt)}</span>
</div> </div>
@@ -128,6 +129,7 @@ function resetForm() {
els.form.elements.publisher.value = "sumi.work"; els.form.elements.publisher.value = "sumi.work";
els.form.elements.sort.value = "100"; els.form.elements.sort.value = "100";
els.form.elements.active.checked = true; els.form.elements.active.checked = true;
els.form.elements.versionManifestContent.value = "";
els.formTitle.textContent = "新增软件"; els.formTitle.textContent = "新增软件";
els.submitButton.textContent = "保存软件"; els.submitButton.textContent = "保存软件";
setStatus(els.formStatus, ""); setStatus(els.formStatus, "");
@@ -150,9 +152,11 @@ function fillForm(item) {
els.form.elements.featured.checked = Boolean(item.featured); els.form.elements.featured.checked = Boolean(item.featured);
els.form.elements.icon.value = ""; els.form.elements.icon.value = "";
els.form.elements.package.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.formTitle.textContent = `更新 ${item.name}`;
els.submitButton.textContent = "更新软件"; els.submitButton.textContent = "更新软件";
setStatus(els.formStatus, "已载入当前软件,选择新安装包即可发布新版本。", "ok"); setStatus(els.formStatus, item.versionManifest ? "已载入当前软件,可替换安装包或直接编辑版本内容 JSON。" : "已载入当前软件,上传安装包且版本内容留空时会自动生成 version.json。", "ok");
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top: 0, behavior: "smooth" });
} }
+32
View File
@@ -79,6 +79,7 @@ const els = {
topicDev: document.querySelector("#topic-dev"), topicDev: document.querySelector("#topic-dev"),
topicSecurity: document.querySelector("#topic-security"), topicSecurity: document.querySelector("#topic-security"),
playerStatus: document.querySelector("#player-status"), playerStatus: document.querySelector("#player-status"),
authLinks: [...document.querySelectorAll("[data-player-auth]")],
}; };
function escapeHtml(value = "") { function escapeHtml(value = "") {
@@ -114,6 +115,10 @@ function getDownloadHref(item) {
return `/api/software/${encodeURIComponent(item.id)}/download`; return `/api/software/${encodeURIComponent(item.id)}/download`;
} }
function getLoginHref(next = "/software.html") {
return `login.html?next=${encodeURIComponent(next)}`;
}
function iconMarkup(item) { function iconMarkup(item) {
const safeName = escapeHtml(item.name); const safeName = escapeHtml(item.name);
if (item.iconUrl) { if (item.iconUrl) {
@@ -281,6 +286,16 @@ async function loadAuth() {
state.authLoaded = true; state.authLoaded = true;
render(); 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() { async function loadSoftware() {
@@ -309,6 +324,23 @@ els.categoryButtons.forEach((button) => {
}); });
els.refreshButton.addEventListener("click", loadSoftware); 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) => { els.playerStatus.addEventListener("click", async (event) => {
if (!event.target.closest("#player-logout")) return; if (!event.target.closest("#player-logout")) return;
await fetch("/api/auth/logout", { method: "POST", headers: { Accept: "application/json" } }); await fetch("/api/auth/logout", { method: "POST", headers: { Accept: "application/json" } });
+354 -18
View File
@@ -13,6 +13,7 @@ const dataDir = path.join(__dirname, "data");
const uploadDir = path.join(__dirname, "uploads"); const uploadDir = path.join(__dirname, "uploads");
const packageDir = path.join(uploadDir, "packages"); const packageDir = path.join(uploadDir, "packages");
const iconDir = path.join(uploadDir, "icons"); const iconDir = path.join(uploadDir, "icons");
const manifestDir = path.join(uploadDir, "manifests");
const dataFile = path.join(dataDir, "software.json"); const dataFile = path.join(dataDir, "software.json");
const playerFile = path.join(dataDir, "players.json"); const playerFile = path.join(dataDir, "players.json");
const port = Number(process.env.PORT || 3030); const port = Number(process.env.PORT || 3030);
@@ -67,6 +68,7 @@ async function ensureStorage() {
fsp.mkdir(dataDir, { recursive: true }), fsp.mkdir(dataDir, { recursive: true }),
fsp.mkdir(packageDir, { recursive: true }), fsp.mkdir(packageDir, { recursive: true }),
fsp.mkdir(iconDir, { recursive: true }), fsp.mkdir(iconDir, { recursive: true }),
fsp.mkdir(manifestDir, { recursive: true }),
]); ]);
try { try {
await fsp.access(dataFile); await fsp.access(dataFile);
@@ -153,6 +155,40 @@ async function hashFile(filePath) {
return hash.digest("hex"); 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")) { function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) {
const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex"); const hash = crypto.pbkdf2Sync(String(password), salt, passwordIterations, 32, "sha256").toString("hex");
return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`; return `pbkdf2_sha256$${passwordIterations}$${salt}$${hash}`;
@@ -254,7 +290,12 @@ function publicItem(item, includePrivate = false) {
if (!includePrivate) { if (!includePrivate) {
delete copy.storagePath; delete copy.storagePath;
delete copy.iconStoragePath; delete copy.iconStoragePath;
delete copy.versionManifestStoragePath;
delete copy.externalDownloadUrl; 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)) { if (resolveDownloadUrl(item)) {
copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`; copy.downloadUrl = `/api/software/${encodeURIComponent(item.id)}/download`;
} }
@@ -274,6 +315,7 @@ async function makeFilePatch(files = {}) {
const patch = {}; const patch = {};
const packageFile = files.package?.[0]; const packageFile = files.package?.[0];
const iconFile = files.icon?.[0]; const iconFile = files.icon?.[0];
const versionManifestFile = files.versionManifest?.[0];
if (packageFile) { if (packageFile) {
patch.storagePath = packageFile.path; patch.storagePath = packageFile.path;
@@ -292,6 +334,16 @@ async function makeFilePatch(files = {}) {
patch.iconUrl = `/uploads/icons/${encodeURIComponent(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; return patch;
} }
@@ -316,7 +368,165 @@ function getBodyPatch(body, existing = {}) {
} }
function resolveDownloadUrl(item) { 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) { function isPathInside(parent, child) {
@@ -332,9 +542,28 @@ function resolvePackagePath(item) {
return ""; 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({ const storage = multer.diskStorage({
destination(req, file, callback) { 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) { filename(req, file, callback) {
const ext = path.extname(file.originalname || "").slice(0, 24); const ext = path.extname(file.originalname || "").slice(0, 24);
@@ -348,11 +577,24 @@ const upload = multer({
storage, storage,
limits: { limits: {
fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024), fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024),
files: 2, files: 3,
}, },
fileFilter(req, file, callback) { fileFilter(req, file, callback) {
if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) { 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; return;
} }
callback(null, true); 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" }); res.status(404).json({ error: "Software not found" });
return; return;
} }
const manifest = buildVersionManifest(item);
res.json({ res.json({
id: item.id, id: item.id,
name: item.name, name: item.name,
version: item.version, version: manifest.version,
changelog: item.changelog, changelog: manifest.changelog,
releaseNotes: manifest.releaseNotes,
release_notes: manifest.release_notes,
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
size: item.size, size: manifest.size,
sizeBytes: item.sizeBytes, sizeBytes: manifest.sizeBytes,
sha256: item.sha256, sha256: manifest.sha256,
downloadUrl: `/api/software/${encodeURIComponent(item.id)}/download`, 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) { } catch (error) {
next(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) => { app.get("/api/software/:id/download", requirePlayer, async (req, res, next) => {
try { try {
const store = await readStore(); 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 { try {
const { items } = await readStore(); const { items } = await readStore();
res.json({ items: sortSoftware(items).map((item) => publicItem(item, true)) }); 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 { try {
const bodyPatch = getBodyPatch(req.body); const bodyPatch = getBodyPatch(req.body);
if (!bodyPatch.name) { if (!bodyPatch.name) {
@@ -518,6 +824,11 @@ app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package",
} }
const filePatch = await makeFilePatch(req.files); 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 now = new Date().toISOString();
const externalDownloadUrl = bodyPatch.externalDownloadUrl; const externalDownloadUrl = bodyPatch.externalDownloadUrl;
const item = { const item = {
@@ -530,6 +841,14 @@ app.post("/api/admin/software", requireAdmin, upload.fields([{ name: "package",
externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl, externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl,
}; };
if (explicitManifest) {
applyVersionManifest(item, explicitManifest, filePatch.versionManifest ? "file" : "manual");
} else if (resolveDownloadUrl(item)) {
applyVersionManifest(item, {}, "auto");
} else {
item.versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`;
item.checkUpdateUrl = `/api/software/${encodeURIComponent(item.id)}/check-update`;
}
store.items.push(item); store.items.push(item);
await writeStore(store); await writeStore(store);
res.status(201).json({ item: publicItem(item, true) }); 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 { try {
const store = await readStore(); const store = await readStore();
const index = store.items.findIndex((item) => item.id === req.params.id); 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 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 downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || "";
const item = { const item = {
...existing, ...existing,
@@ -563,6 +887,17 @@ app.put("/api/admin/software/:id", requireAdmin, upload.fields([{ name: "package
downloadUrl, downloadUrl,
externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl, 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; store.items[index] = item;
await writeStore(store); 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 { try {
const store = await readStore(); const store = await readStore();
const initialLength = store.items.length; 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.redirect(301, "/software.html"));
app.get("/software/", (req, res) => res.sendFile(path.join(projectRoot, "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("/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("/", (req, res) => res.sendFile(path.join(projectRoot, "index.html")));
app.get("/index.html", (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("/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("/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("/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); 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(); await ensureStorage();
+1
View File
@@ -0,0 +1 @@
+3 -3
View File
@@ -18,7 +18,7 @@
<nav class="soft-nav" aria-label="软件中心导航"> <nav class="soft-nav" aria-label="软件中心导航">
<a href="index.html">首页</a> <a href="index.html">首页</a>
<a class="is-active" href="software.html">软件下载</a> <a class="is-active" href="software.html">软件下载</a>
<a href="admin/software.html">管理后台</a> <a href="admin/software.html" data-player-auth data-next="/admin/software.html">管理后台</a>
</nav> </nav>
<form class="soft-search" role="search"> <form class="soft-search" role="search">
<label class="sr-only" for="software-keyword">搜索软件</label> <label class="sr-only" for="software-keyword">搜索软件</label>
@@ -41,7 +41,7 @@
<h1 id="download-title">海量软件下载,<span>管理无忧</span></h1> <h1 id="download-title">海量软件下载,<span>管理无忧</span></h1>
<p class="hero-copy">统一沉淀安装包、版本号、更新记录和下载入口,让常用软件找得到、下得准、更新快。</p> <p class="hero-copy">统一沉淀安装包、版本号、更新记录和下载入口,让常用软件找得到、下得准、更新快。</p>
<div class="hero-actions"> <div class="hero-actions">
<a class="primary-action" href="#software-list"> <a class="primary-action" href="#software-list" data-player-auth data-next="/software.html#software-list">
<svg aria-hidden="true" viewBox="0 0 24 24"> <svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M12 3v12"></path> <path d="M12 3v12"></path>
<path d="m7 10 5 5 5-5"></path> <path d="m7 10 5 5 5-5"></path>
@@ -49,7 +49,7 @@
</svg> </svg>
开始下载 开始下载
</a> </a>
<a class="secondary-action" href="admin/software.html"> <a class="secondary-action" href="admin/software.html" data-player-auth data-next="/admin/software.html">
<svg aria-hidden="true" viewBox="0 0 24 24"> <svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M12 3 5 6v5c0 4.6 2.8 8.5 7 10 4.2-1.5 7-5.4 7-10V6l-7-3Z"></path> <path d="M12 3 5 6v5c0 4.6 2.8 8.5 7 10 4.2-1.5 7-5.4 7-10V6l-7-3Z"></path>
<path d="m9.5 12 1.7 1.7 3.6-4"></path> <path d="m9.5 12 1.7 1.7 3.6-4"></path>