Add software version manifest management
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user