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 @@