Add software version manifest management

This commit is contained in:
lwt
2026-06-08 22:02:51 +08:00
parent 228d625e16
commit b272026ac9
8 changed files with 508 additions and 26 deletions
+354 -18
View File
@@ -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();
+1
View File
@@ -0,0 +1 @@