From c178a7c54024b5284b8b01d6e2c1e28df0b0171b Mon Sep 17 00:00:00 2001 From: lwt <215586800@qq.com> Date: Tue, 9 Jun 2026 11:46:23 +0800 Subject: [PATCH] Support delta package updates --- admin/software.html | 8 ++ assets/js/software-admin.js | 2 + server/server.js | 197 ++++++++++++++++++++++++++++++++++-- 3 files changed, 200 insertions(+), 7 deletions(-) diff --git a/admin/software.html b/admin/software.html index eb2306c..304260a 100644 --- a/admin/software.html +++ b/admin/software.html @@ -99,6 +99,14 @@ +
+ + +
+
+ + +
diff --git a/assets/js/software-admin.js b/assets/js/software-admin.js index 54e6efd..fb1c70d 100644 --- a/assets/js/software-admin.js +++ b/assets/js/software-admin.js @@ -152,6 +152,8 @@ function fillForm(item) { els.form.elements.featured.checked = Boolean(item.featured); els.form.elements.icon.value = ""; els.form.elements.package.value = ""; + els.form.elements.deltaFromVersion.value = ""; + els.form.elements.deltaPackage.value = ""; els.form.elements.versionManifest.value = ""; els.form.elements.versionManifestContent.value = item.versionManifest ? JSON.stringify(item.versionManifest, null, 2) : ""; els.formTitle.textContent = `更新 ${item.name}`; diff --git a/server/server.js b/server/server.js index 4547830..e2cb453 100644 --- a/server/server.js +++ b/server/server.js @@ -308,6 +308,7 @@ function publicItem(item, includePrivate = false) { delete copy.storagePath; delete copy.iconStoragePath; delete copy.versionManifestStoragePath; + delete copy.deltaPackages; delete copy.externalDownloadUrl; delete copy.versionManifest; copy.hasVersionManifest = Boolean(item.versionManifest); @@ -364,6 +365,70 @@ async function makeFilePatch(files = {}) { return patch; } +async function makeDeltaPackage(file, fromVersion, toVersion) { + const sourceVersion = normalizeVersion(fromVersion); + const targetVersion = normalizeVersion(toVersion); + if (!file) return null; + if (!sourceVersion) { + const error = new Error("Delta package source version is required"); + error.status = 400; + throw error; + } + return { + fromVersion: sourceVersion, + toVersion: targetVersion, + storagePath: file.path, + originalFileName: file.originalname, + fileName: file.filename, + sizeBytes: file.size, + size: formatSize(file.size), + sha256: await hashFile(file.path), + uploadedAt: new Date().toISOString(), + type: "file-zip", + }; +} + +function getManifestDeltaMap(manifest = {}) { + const map = manifest.delta_updates || manifest.deltaUpdates || manifest.delta || {}; + return map && typeof map === "object" && !Array.isArray(map) ? map : {}; +} + +function inferDeltaFromVersion(manifest = {}) { + const keys = Object.keys(getManifestDeltaMap(manifest)); + return keys.length === 1 ? keys[0] : ""; +} + +function mergeDeltaPackageIntoManifest(sourceManifest = {}, deltaPackage, itemId) { + sourceManifest = sourceManifest && typeof sourceManifest === "object" ? sourceManifest : {}; + if (!deltaPackage) return sourceManifest; + const fromVersion = deltaPackage.fromVersion; + const deltaUrl = `/api/software/${encodeURIComponent(itemId)}/delta-download?from=${encodeURIComponent(fromVersion)}`; + const next = { + ...sourceManifest, + delta_updates: { + ...getManifestDeltaMap(sourceManifest), + [fromVersion]: { + ...(getManifestDeltaMap(sourceManifest)[fromVersion] || {}), + type: "file-zip", + format: "server-manager-file-delta-v1", + fromVersion, + from_version: fromVersion, + targetVersion: deltaPackage.toVersion || sourceManifest.version || "", + target_version: deltaPackage.toVersion || sourceManifest.version || "", + package_url: deltaUrl, + packageUrl: deltaUrl, + patch_url: deltaUrl, + patchUrl: deltaUrl, + sha256: deltaPackage.sha256, + sizeBytes: deltaPackage.sizeBytes, + size: deltaPackage.size, + }, + }, + }; + next.deltaUpdates = next.delta_updates; + return next; +} + function getBodyPatch(body, existing = {}) { const now = new Date().toISOString(); return { @@ -430,10 +495,57 @@ function getUpdateDownloadUrl(item) { return resolveDownloadUrl(item) ? `/api/software/${encodeURIComponent(item.id)}/update-download` : ""; } +function getDeltaDownloadUrl(item, fromVersion) { + const deltaPackage = item.deltaPackages?.[normalizeVersion(fromVersion)]; + return deltaPackage ? `/api/software/${encodeURIComponent(item.id)}/delta-download?from=${encodeURIComponent(normalizeVersion(fromVersion))}` : ""; +} + function getReleaseNotes(item, manifest) { return manifest.release_notes || manifest.releaseNotes || manifest.changelog || item.changelog || ""; } +function buildPublicDeltaUpdates(item, req, sourceManifest = {}) { + const manifestDeltaMap = getManifestDeltaMap(sourceManifest); + const result = {}; + const allFromVersions = new Set([ + ...Object.keys(manifestDeltaMap), + ...Object.keys(item.deltaPackages || {}), + ]); + + for (const fromVersion of allFromVersions) { + const normalizedFrom = normalizeVersion(fromVersion); + if (!normalizedFrom) continue; + const sourceEntry = manifestDeltaMap[fromVersion] || manifestDeltaMap[normalizedFrom] || {}; + if (!sourceEntry || typeof sourceEntry !== "object" || Array.isArray(sourceEntry)) continue; + + const deltaPackage = item.deltaPackages?.[normalizedFrom]; + if (deltaPackage) { + const deltaUrl = absoluteUrl(req, getDeltaDownloadUrl(item, normalizedFrom)); + result[normalizedFrom] = { + ...sourceEntry, + type: sourceEntry.type || deltaPackage.type || "file-zip", + format: sourceEntry.format || "server-manager-file-delta-v1", + fromVersion: sourceEntry.fromVersion || sourceEntry.from_version || normalizedFrom, + from_version: sourceEntry.from_version || sourceEntry.fromVersion || normalizedFrom, + targetVersion: sourceEntry.targetVersion || sourceEntry.target_version || deltaPackage.toVersion || getLatestVersion(item), + target_version: sourceEntry.target_version || sourceEntry.targetVersion || deltaPackage.toVersion || getLatestVersion(item), + packageUrl: deltaUrl, + package_url: deltaUrl, + patchUrl: deltaUrl, + patch_url: deltaUrl, + sha256: sourceEntry.sha256 || deltaPackage.sha256, + sizeBytes: sourceEntry.sizeBytes ?? deltaPackage.sizeBytes, + size: sourceEntry.size || deltaPackage.size, + }; + continue; + } + + result[normalizedFrom] = { ...sourceEntry }; + } + + return result; +} + function createStoredVersionManifest(item, sourceManifest = {}) { const versionManifestUrl = `/api/software/${encodeURIComponent(item.id)}/version.json`; const downloadUrl = getProtectedDownloadUrl(item); @@ -481,6 +593,7 @@ function buildVersionManifest(item, req) { const updateUrl = absoluteUrl(req, versionManifestUrl); const releaseNotes = getReleaseNotes(item, sourceManifest); const minimumVersion = sourceManifest.min_supported_version || sourceManifest.minSupportedVersion || sourceManifest.minimumVersion || sourceManifest.minVersion || ""; + const deltaUpdates = buildPublicDeltaUpdates(item, req, sourceManifest); return { ...sourceManifest, @@ -504,8 +617,8 @@ function buildVersionManifest(item, req) { 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 || {}, + deltaUpdates, + delta_updates: deltaUpdates, checkUpdateUrl: absoluteUrl(req, `/api/software/${encodeURIComponent(item.id)}/check-update`), versionManifestUrl: updateUrl, }; @@ -576,6 +689,12 @@ function resolvePackagePath(item) { return publicPackagePath; } +function resolveDeltaPackagePath(item, fromVersion) { + const deltaPackage = item.deltaPackages?.[normalizeVersion(fromVersion)]; + if (!deltaPackage?.storagePath) return ""; + return path.resolve(deltaPackage.storagePath); +} + async function cleanupUploadedFiles(req) { const uploadedFiles = Object.values(req.files || {}).flat(); for (const file of uploadedFiles) { @@ -611,7 +730,7 @@ const upload = multer({ storage, limits: { fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024), - files: 3, + files: 4, }, fileFilter(req, file, callback) { if (file.fieldname === "icon" && !file.mimetype.startsWith("image/")) { @@ -833,7 +952,43 @@ async function sendSoftwareDownload(req, res, next) { } } +async function sendSoftwareDeltaDownload(req, res, next) { + try { + const fromVersion = normalizeVersion(req.query.from || req.query.version || req.query.currentVersion || ""); + if (!fromVersion) { + res.status(400).json({ error: "Delta source version is required. Use ?from=1.0.0" }); + return; + } + + const store = await readStore(); + const item = store.items.find((entry) => entry.id === req.params.id && entry.active !== false); + if (!item) { + res.status(404).json({ error: "Software not found" }); + return; + } + + const deltaPackage = item.deltaPackages?.[fromVersion]; + const deltaPath = resolveDeltaPackagePath(item, fromVersion); + if (!deltaPackage || !deltaPath) { + res.status(404).json({ error: "Delta package not found" }); + return; + } + if (!isPathInside(packageDir, deltaPath)) { + res.status(403).json({ error: "Invalid delta package path" }); + return; + } + + await fsp.access(deltaPath); + item.deltaDownloadCount = (item.deltaDownloadCount || 0) + 1; + await writeStore(store); + res.download(deltaPath, deltaPackage.originalFileName || deltaPackage.fileName || path.basename(deltaPath)); + } catch (error) { + next(error); + } +} + app.get("/api/software/:id/update-download", sendSoftwareDownload); +app.get("/api/software/:id/delta-download", sendSoftwareDeltaDownload); app.get("/api/software/:id/download", requirePlayer, sendSoftwareDownload); app.get("/api/admin/software", requirePlayer, requireAdmin, async (req, res, next) => { @@ -845,7 +1000,14 @@ app.get("/api/admin/software", requirePlayer, requireAdmin, async (req, res, nex } }); -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) => { +const softwareUploadFields = [ + { name: "package", maxCount: 1 }, + { name: "deltaPackage", maxCount: 1 }, + { name: "icon", maxCount: 1 }, + { name: "versionManifest", maxCount: 1 }, +]; + +app.post("/api/admin/software", requirePlayer, requireAdmin, upload.fields(softwareUploadFields), async (req, res, next) => { try { const bodyPatch = getBodyPatch(req.body); if (!bodyPatch.name) { @@ -862,10 +1024,15 @@ app.post("/api/admin/software", requirePlayer, requireAdmin, upload.fields([{ na const filePatch = await makeFilePatch(req.files); const contentManifest = parseVersionManifestContent(req.body.versionManifestContent); - const explicitManifest = filePatch.versionManifest || contentManifest; + let explicitManifest = filePatch.versionManifest || contentManifest; if (explicitManifest?.version) { bodyPatch.version = String(explicitManifest.version).trim(); } + const deltaPackage = await makeDeltaPackage( + req.files?.deltaPackage?.[0], + req.body.deltaFromVersion || inferDeltaFromVersion(explicitManifest || {}), + explicitManifest?.version || bodyPatch.version, + ); const now = new Date().toISOString(); const externalDownloadUrl = bodyPatch.externalDownloadUrl; const item = { @@ -874,10 +1041,14 @@ app.post("/api/admin/software", requirePlayer, requireAdmin, upload.fields([{ na downloadCount: 0, ...bodyPatch, ...filePatch, + deltaPackages: deltaPackage ? { [deltaPackage.fromVersion]: deltaPackage } : {}, downloadUrl: filePatch.downloadUrl || externalDownloadUrl, externalDownloadUrl: filePatch.downloadUrl ? "" : externalDownloadUrl, }; + if (deltaPackage) { + explicitManifest = mergeDeltaPackageIntoManifest(explicitManifest || {}, deltaPackage, item.id); + } if (explicitManifest) { applyVersionManifest(item, explicitManifest, filePatch.versionManifest ? "file" : "manual"); } else if (resolveDownloadUrl(item)) { @@ -894,7 +1065,7 @@ app.post("/api/admin/software", requirePlayer, requireAdmin, upload.fields([{ na } }); -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) => { +app.put("/api/admin/software/:id", requirePlayer, requireAdmin, upload.fields(softwareUploadFields), async (req, res, next) => { try { const store = await readStore(); const index = store.items.findIndex((item) => item.id === req.params.id); @@ -912,18 +1083,30 @@ app.put("/api/admin/software/:id", requirePlayer, requireAdmin, upload.fields([{ const filePatch = await makeFilePatch(req.files); const contentManifest = parseVersionManifestContent(req.body.versionManifestContent); - const explicitManifest = filePatch.versionManifest || contentManifest; + let explicitManifest = filePatch.versionManifest || contentManifest; if (explicitManifest?.version) { bodyPatch.version = String(explicitManifest.version).trim(); } + const deltaPackage = await makeDeltaPackage( + req.files?.deltaPackage?.[0], + req.body.deltaFromVersion || inferDeltaFromVersion(explicitManifest || {}), + explicitManifest?.version || bodyPatch.version || existing.version, + ); const downloadUrl = filePatch.downloadUrl || bodyPatch.externalDownloadUrl || existing.downloadUrl || ""; const item = { ...existing, ...bodyPatch, ...filePatch, + deltaPackages: { + ...(existing.deltaPackages || {}), + ...(deltaPackage ? { [deltaPackage.fromVersion]: deltaPackage } : {}), + }, downloadUrl, externalDownloadUrl: filePatch.downloadUrl ? "" : bodyPatch.externalDownloadUrl, }; + if (deltaPackage) { + explicitManifest = mergeDeltaPackageIntoManifest(explicitManifest || {}, deltaPackage, item.id); + } if (explicitManifest) { applyVersionManifest(item, explicitManifest, filePatch.versionManifest ? "file" : "manual"); } else if (filePatch.downloadUrl || bodyPatch.externalDownloadUrl || !item.versionManifest) {