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) {