Support delta package updates
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user