Vydané zdroje
Stáhnout zdroje ZIP
CH-J Server Manager
Procházejte adresáře a soubory konkrétního vydání aplikace.
Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const path = require("node:path");6
const { Readable, Transform } = require("node:stream");7
const { pipeline } = require("node:stream/promises");8
const { compareVersions } = require("../../shared/version");9
const { assertAllowedUrl, normalizeBaseUrl } = require("../security/urlPolicy");10
const { cleanDownloadCache } = require("../storage/downloadCache");11
const {12
assertSupportedUpdateTarget,13
normalizeArchitecture,14
normalizePlatform,15
serverChannel,16
validateReleaseListResponse,17
validateReleaseResponse18
} = require("../../shared/updateContract");20
const MAX_MANIFEST_BYTES = 256 * 1024;21
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024;22
const MAX_SIGNATURE_BYTES = 128 * 1024;24
function timeoutSignal(timeoutMs) {25
const controller = new AbortController();26
const timer = setTimeout(() => controller.abort(new Error("Request timeout.")), timeoutMs);27
timer.unref?.();28
return { signal: controller.signal, clear: () => clearTimeout(timer) };29
}31
function safeFilename(value) {32
const basename = path.basename(String(value || "update.bin"));33
const safe = basename.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+/, "");34
return safe || "update.bin";35
}37
async function sha512File(filePath) {38
const hash = crypto.createHash("sha512");39
const stream = fs.createReadStream(filePath);40
for await (const chunk of stream) hash.update(chunk);41
return hash.digest("hex");42
}44
class HashUrlProvider {45
constructor(options) {46
this.baseUrls = (options.baseUrls || []).map((value) => normalizeBaseUrl(value).toString());47
if (this.baseUrls.length === 0) throw new Error("At least one update base URL is required.");48
this.fetch = options.fetchImpl || globalThis.fetch;49
if (typeof this.fetch !== "function") throw new Error("Fetch API is unavailable.");50
this.downloadRoot = options.downloadRoot;51
this.logger = options.logger;52
this.signatureVerifier = options.signatureVerifier;53
this.requestTimeoutMs = Number(options.requestTimeoutMs || 5000);54
this.downloadTimeoutMs = Number(options.downloadTimeoutMs || 15 * 60 * 1000);55
}57
async checkForUpdates(options) {58
const currentVersion = String(options.currentVersion || "");59
const platform = normalizePlatform(options.platform);60
const arch = normalizeArchitecture(options.arch);61
const channel = String(options.channel || "alpha");62
const errors = [];64
assertSupportedUpdateTarget(platform, arch);66
for (const baseUrl of this.baseUrls) {67
try {68
const url = new URL("api/latest.php", baseUrl);69
url.searchParams.set("platform", platform);70
url.searchParams.set("arch", arch);71
url.searchParams.set("channel", serverChannel(channel));72
url.searchParams.set("current_version", currentVersion);73
const response = await this._fetchJson(url);74
if (response.status === 404) {75
return { updateAvailable: false, currentVersion, release: null, sourceBaseUrl: baseUrl };76
}77
if (response.status !== 200) throw new Error(`Update server returned HTTP ${response.status}.`);78
const result = validateReleaseResponse(response.payload, { currentVersion, platform, arch, channel });79
if (result.release) {80
result.release.downloadUrl = this._resolveArtifactUrl(result.release.downloadUrl, baseUrl).toString();81
result.release.signatureUrl = this._resolveSignatureUrl(82
result.release.signatureUrl,83
result.release.downloadUrl,84
baseUrl85
).toString();86
}87
return { ...result, sourceBaseUrl: baseUrl };88
} catch (error) {89
errors.push({ baseUrl, message: error?.message || String(error) });90
this.logger?.warn("Update endpoint failed.", errors.at(-1));91
}92
}93
const error = new Error("No configured update endpoint is reachable.");94
error.code = "UPDATE_ENDPOINT_UNREACHABLE";95
error.details = errors;96
throw error;97
}99
async listReleases(options) {100
const currentVersion = String(options.currentVersion || "");101
const platform = normalizePlatform(options.platform);102
const arch = normalizeArchitecture(options.arch);103
const channel = String(options.channel || "alpha");104
const errors = [];105
const channels = channel === "all" ? ["alpha", "beta", "stable"] : [serverChannel(channel)];107
assertSupportedUpdateTarget(platform, arch);108
for (const baseUrl of this.baseUrls) {109
try {110
const releasesById = new Map();111
for (const selectedChannel of channels) {112
const url = new URL("api/releases.php", baseUrl);113
url.searchParams.set("platform", platform);114
url.searchParams.set("arch", arch);115
url.searchParams.set("channel", selectedChannel);116
const response = await this._fetchJson(url);117
if (response.status !== 200) throw new Error(`Update server returned HTTP ${response.status}.`);118
const result = validateReleaseListResponse(response.payload, { currentVersion, platform, arch, channel: selectedChannel });119
for (const release of result.releases) {120
release.downloadUrl = this._resolveArtifactUrl(release.downloadUrl, baseUrl).toString();121
release.signatureUrl = this._resolveSignatureUrl(122
release.signatureUrl,123
release.downloadUrl,124
baseUrl125
).toString();126
releasesById.set(release.id, release);127
}128
}129
const releases = [...releasesById.values()].sort((a, b) => compareVersions(b.version, a.version) || Date.parse(b.publishedAt) - Date.parse(a.publishedAt));130
return { currentVersion, releases, sourceBaseUrl: baseUrl };131
} catch (error) {132
errors.push({ baseUrl, message: error?.message || String(error) });133
this.logger?.warn("Update catalog endpoint failed.", errors.at(-1));134
}135
}136
const error = new Error("No configured update catalog endpoint is reachable.");137
error.code = "UPDATE_ENDPOINT_UNREACHABLE";138
error.details = errors;139
throw error;140
}142
async downloadAndVerify(release) {143
if (!release || typeof release !== "object") throw new Error("No update release selected.");144
if (!this.signatureVerifier?.verifyFile) {145
throw new Error("OpenPGP update verification is unavailable.");146
}147
if (!Number.isSafeInteger(release.size) || release.size <= 0 || release.size > MAX_ARTIFACT_BYTES) {148
throw new Error("Update artifact size is outside the allowed range.");149
}151
const url = this._resolveArtifactUrl(release.downloadUrl, this.baseUrls[0]);152
const signatureUrl = this._resolveSignatureUrl(release.signatureUrl, url, this.baseUrls[0]);153
const filename = safeFilename(release.filename);154
const versionDir = path.join(this.downloadRoot, safeFilename(`${release.version}-${release.id || "release"}`));155
const destination = path.join(versionDir, filename);156
const signaturePath = `${destination}.asc`;157
this._ensurePrivateDirectory(this.downloadRoot, true);158
this._ensurePrivateDirectory(versionDir, false);160
let reused = false;161
if (fs.existsSync(destination)) {162
const stat = fs.lstatSync(destination);163
if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1164
&& stat.size === release.size && await sha512File(destination) === release.sha512) {165
reused = true;166
} else {167
fs.unlinkSync(destination);168
}169
}171
try {172
if (!reused) await this._downloadArtifact(url, release, destination, versionDir, filename);173
await this._downloadSignature(signatureUrl, signaturePath, versionDir, filename);174
this._assertSafeDownloadedFile(destination, release.size, "Update artifact");175
this._assertSafeDownloadedFile(signaturePath, null, "Detached OpenPGP signature");176
const signature = await this.signatureVerifier.verifyFile(destination, signaturePath);177
if (signature?.valid !== true) throw new Error("Mandatory OpenPGP verification did not return a valid result.");178
try { fs.chmodSync(destination, 0o400); } catch {}179
try { fs.chmodSync(signaturePath, 0o400); } catch {}180
const stat = fs.lstatSync(destination);181
return {182
path: destination,183
signaturePath,184
filename,185
size: stat.size,186
sha512: release.sha512,187
reused,188
signatureVerified: signature.valid === true,189
primaryFingerprint: signature.primaryFingerprint,190
signingFingerprints: signature.signingFingerprints,191
signatureCreatedAt: signature.signatureCreatedAt192
};193
} catch (error) {194
try { fs.unlinkSync(signaturePath); } catch {}195
try { fs.unlinkSync(destination); } catch {}196
throw error;197
}198
}200
async reverifyForInstall(download, release) {201
if (!download?.signatureVerified || !this.signatureVerifier?.verifyFile) {202
throw new Error("No OpenPGP-verified update is ready to install.");203
}204
const filename = safeFilename(release?.filename);205
const versionDir = path.join(this.downloadRoot, safeFilename(`${release?.version}-${release?.id || "release"}`));206
const expectedPath = path.resolve(versionDir, filename);207
const expectedSignaturePath = path.resolve(`${expectedPath}.asc`);208
if (path.resolve(download.path) !== expectedPath || path.resolve(download.signaturePath) !== expectedSignaturePath) {209
throw new Error("Verified update paths do not match the selected release.");210
}212
this._ensurePrivateDirectory(this.downloadRoot, true);213
this._ensurePrivateDirectory(versionDir, false);214
const artifactStat = this._assertSafeDownloadedFile(expectedPath, release.size, "Update artifact");215
this._assertSafeDownloadedFile(expectedSignaturePath, null, "Detached OpenPGP signature");216
const actualHash = await sha512File(expectedPath);217
if (actualHash !== release.sha512 || artifactStat.size !== release.size) {218
throw new Error("The downloaded update changed after verification.");219
}220
const signature = await this.signatureVerifier.verifyFile(expectedPath, expectedSignaturePath);221
if (signature.valid !== true) throw new Error("The downloaded update signature is no longer valid.");222
return expectedPath;223
}225
cleanupDownloads() {226
return cleanDownloadCache(this.downloadRoot);227
}229
_resolveArtifactUrl(value, sourceBaseUrl) {230
const raw = String(value || "").trim();231
const resolved = new URL(raw.replace(/^\/+/, ""), sourceBaseUrl).toString();232
return assertAllowedUrl(resolved, this.baseUrls, { pathSegment: "files/" });233
}235
_resolveSignatureUrl(value, artifactUrl, sourceBaseUrl) {236
const raw = String(value || "").trim();237
const resolved = assertAllowedUrl(238
new URL(raw.replace(/^\/+/, ""), sourceBaseUrl).toString(),239
this.baseUrls,240
{ pathSegment: "files/" }241
);242
const artifact = new URL(artifactUrl);243
if (resolved.origin !== artifact.origin || resolved.pathname !== `${artifact.pathname}.asc` || resolved.search || resolved.hash) {244
throw new Error("Detached signature URL must be the artifact URL followed by .asc.");245
}246
return resolved;247
}249
_assertSafeDownloadedFile(filePath, expectedSize, label) {250
const stat = fs.lstatSync(filePath);251
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {252
throw new Error(`${label} is not a safe regular file.`);253
}254
if (expectedSize != null && stat.size !== expectedSize) {255
throw new Error(`${label} size changed after verification.`);256
}257
const realRoot = fs.realpathSync(this.downloadRoot);258
const realFile = fs.realpathSync(filePath);259
const relative = path.relative(realRoot, realFile);260
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {261
throw new Error(`${label} resolves outside the private update cache.`);262
}263
return stat;264
}266
_ensurePrivateDirectory(directoryPath, recursive) {267
if (!fs.existsSync(directoryPath)) fs.mkdirSync(directoryPath, { recursive, mode: 0o700 });268
const stat = fs.lstatSync(directoryPath);269
if (!stat.isDirectory() || stat.isSymbolicLink()) {270
throw new Error("Update download directory is unsafe.");271
}272
try { fs.chmodSync(directoryPath, 0o700); } catch {}273
}275
async _downloadArtifact(url, release, destination, versionDir, filename) {276
const tempPath = path.join(versionDir, `.${filename}.${crypto.randomBytes(6).toString("hex")}.part`);277
const timeout = timeoutSignal(this.downloadTimeoutMs);278
try {279
const response = await this.fetch(url, {280
method: "GET",281
redirect: "manual",282
signal: timeout.signal,283
headers: { Accept: "application/octet-stream" }284
});285
if (response.status !== 200) throw new Error(`Download server returned HTTP ${response.status}.`);286
if (!response.body) throw new Error("Download response has no body.");287
const declaredLength = Number(response.headers.get("content-length") || 0);288
if (declaredLength > 0 && declaredLength !== release.size) throw new Error("Download Content-Length does not match the catalog.");290
const hash = crypto.createHash("sha512");291
let received = 0;292
const verifier = new Transform({293
transform(chunk, _encoding, callback) {294
received += chunk.length;295
if (received > release.size || received > MAX_ARTIFACT_BYTES) {296
callback(new Error("Downloaded artifact exceeds the expected size."));297
return;298
}299
hash.update(chunk);300
callback(null, chunk);301
}302
});304
await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));305
const actualHash = hash.digest("hex");306
if (received !== release.size) throw new Error(`Downloaded size mismatch: expected ${release.size}, received ${received}.`);307
if (actualHash !== release.sha512) throw new Error("Downloaded SHA-512 does not match the catalog.");308
fs.renameSync(tempPath, destination);309
} catch (error) {310
try { fs.unlinkSync(tempPath); } catch {}311
throw error;312
} finally {313
timeout.clear();314
}315
}317
async _downloadSignature(url, destination, versionDir, filename) {318
try { fs.unlinkSync(destination); } catch {}319
const tempPath = path.join(versionDir, `.${filename}.${crypto.randomBytes(6).toString("hex")}.asc.part`);320
const timeout = timeoutSignal(this.requestTimeoutMs);321
try {322
const response = await this.fetch(url, {323
method: "GET",324
redirect: "manual",325
signal: timeout.signal,326
headers: { Accept: "application/pgp-signature, application/octet-stream, text/plain" }327
});328
if (response.status !== 200) throw new Error(`Signature server returned HTTP ${response.status}.`);329
if (!response.body) throw new Error("Signature response has no body.");330
const declaredLength = Number(response.headers.get("content-length") || 0);331
if (declaredLength > MAX_SIGNATURE_BYTES) throw new Error("Detached signature is too large.");332
let received = 0;333
const limiter = new Transform({334
transform(chunk, _encoding, callback) {335
received += chunk.length;336
if (received > MAX_SIGNATURE_BYTES) return callback(new Error("Detached signature is too large."));337
callback(null, chunk);338
}339
});340
await pipeline(Readable.fromWeb(response.body), limiter, fs.createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));341
if (received <= 0) throw new Error("Detached signature is empty.");342
fs.renameSync(tempPath, destination);343
} catch (error) {344
try { fs.unlinkSync(tempPath); } catch {}345
throw error;346
} finally {347
timeout.clear();348
}349
}351
async _fetchJson(url) {352
assertAllowedUrl(url.toString(), this.baseUrls, { pathSegment: "api/" });353
const timeout = timeoutSignal(this.requestTimeoutMs);354
try {355
const response = await this.fetch(url, {356
method: "GET",357
redirect: "manual",358
signal: timeout.signal,359
headers: { Accept: "application/json" }360
});361
if (response.status === 404) return { status: 404, payload: null };362
const declaredLength = Number(response.headers.get("content-length") || 0);363
if (declaredLength > MAX_MANIFEST_BYTES) throw new Error("Update response is too large.");364
const text = await response.text();365
if (Buffer.byteLength(text, "utf8") > MAX_MANIFEST_BYTES) throw new Error("Update response is too large.");366
let payload;367
try { payload = JSON.parse(text); } catch { throw new Error("Update server returned invalid JSON."); }368
return { status: response.status, payload };369
} finally {370
timeout.clear();371
}372
}373
}375
module.exports = {376
HashUrlProvider,377
MAX_ARTIFACT_BYTES,378
MAX_MANIFEST_BYTES,379
MAX_SIGNATURE_BYTES,380
safeFilename,381
sha512File382
};SHA-256: b92e768194ed6a2709f5bc4ee9b707c14aafca0afdce4abb445a04374c7bb13b
SHA-256 archivu: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0