Support delta package updates

This commit is contained in:
lwt
2026-06-09 11:46:23 +08:00
parent 8fa911390a
commit c178a7c540
3 changed files with 200 additions and 7 deletions
+8
View File
@@ -99,6 +99,14 @@
<label for="package">安装包</label>
<input id="package" name="package" type="file" />
</div>
<div class="field">
<label for="delta-from-version">增量包来源版本</label>
<input id="delta-from-version" name="deltaFromVersion" maxlength="40" placeholder="1.1.7" />
</div>
<div class="field">
<label for="delta-package">增量包</label>
<input id="delta-package" name="deltaPackage" type="file" accept=".zip,.smdelta,application/zip" />
</div>
<div class="field full">
<label for="version-manifest">版本清单 version.json</label>
<input id="version-manifest" name="versionManifest" type="file" accept="application/json,.json" />
+2
View File
@@ -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}`;
+190 -7
View File
@@ -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) {