Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
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 { assertAllowedUrl, normalizeBaseUrl } = require("../security/urlPolicy");9
const { SHA512_PATTERN, serverChannel } = require("../../shared/updateContract");10
const { compareVersions, parseVersion } = require("../../shared/version");11
const { ALLOWED_PERMISSIONS, PLUGIN_ID_PATTERN } = require("./pluginRegistry");12
const { cleanDownloadCache, removeCachedFile } = require("../storage/downloadCache");14
const MAX_CATALOG_BYTES = 512 * 1024;15
const MAX_PLUGIN_BYTES = 512 * 1024 * 1024;17
function requiredString(value, field) {18
const normalized = String(value || "").trim();19
if (!normalized) throw new Error(`Missing ${field}.`);20
return normalized;21
}23
function validatePluginCatalog(payload, expected) {24
if (!payload || typeof payload !== "object" || !Array.isArray(payload.plugins)) {25
throw new Error("Plugin catalog must contain a plugins array.");26
}27
const ids = new Set();28
return payload.plugins.map((source) => {29
const id = requiredString(source.plugin_id, "plugin_id");30
if (!PLUGIN_ID_PATTERN.test(id)) throw new Error(`Invalid plugin ID: ${id}`);31
if (ids.has(id)) throw new Error(`Duplicate plugin ID: ${id}`);32
ids.add(id);33
const version = requiredString(source.version, "version");34
parseVersion(version);35
const minAppVersion = requiredString(source.min_app_version || "0.0.1", "min_app_version");36
parseVersion(minAppVersion);37
const pluginApi = requiredString(source.plugin_api || "^1.0.0", "plugin_api");38
if (!/^\^1\.\d+\.\d+$/.test(pluginApi)) throw new Error(`Unsupported plugin API: ${pluginApi}`);39
const permissions = Array.isArray(source.permissions) ? [...new Set(source.permissions.map(String))] : [];40
for (const permission of permissions) {41
if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`Unsupported plugin permission: ${permission}`);42
}43
const sha512 = requiredString(source.sha512, "sha512").toLowerCase();44
if (!SHA512_PATTERN.test(sha512)) throw new Error(`Invalid SHA-512 for ${id}`);45
const size = Number(source.size);46
if (!Number.isSafeInteger(size) || size <= 0 || size > MAX_PLUGIN_BYTES) throw new Error(`Invalid package size for ${id}`);47
const channel = requiredString(source.channel || "stable", "channel").toLowerCase();48
if (channel !== serverChannel(expected.channel)) throw new Error(`Unexpected plugin channel: ${channel}`);49
const publishedAt = requiredString(source.published_at, "published_at");50
if (Number.isNaN(Date.parse(publishedAt))) throw new Error(`Invalid publication date for ${id}`);51
return {52
releaseId: requiredString(source.id, "id"),53
id,54
name: requiredString(source.name || id, "name"),55
version,56
minAppVersion,57
pluginApi,58
permissions,59
channel,60
notes: String(source.notes || ""),61
filename: requiredString(source.filename, "filename"),62
size,63
sha512,64
downloadUrl: requiredString(source.download_url || source.download_path, "download_url"),65
publishedAt66
};67
});68
}70
function timeoutSignal(timeoutMs) {71
const controller = new AbortController();72
const timer = setTimeout(() => controller.abort(new Error("Request timeout.")), timeoutMs);73
timer.unref?.();74
return { signal: controller.signal, clear: () => clearTimeout(timer) };75
}77
async function sha512File(filePath) {78
const hash = crypto.createHash("sha512");79
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);80
return hash.digest("hex");81
}83
function integrityError(message) {84
const error = new Error(message);85
error.code = "PLUGIN_ARTIFACT_INVALID";86
return error;87
}89
class PluginCatalogProvider {90
constructor(options) {91
this.baseUrls = (options.baseUrls || []).map((value) => normalizeBaseUrl(value).toString());92
if (!this.baseUrls.length) throw new Error("At least one plugin catalog URL is required.");93
this.fetch = options.fetchImpl || globalThis.fetch;94
this.downloadRoot = options.downloadRoot;95
this.logger = options.logger;96
this.requestTimeoutMs = Number(options.requestTimeoutMs || 5000);97
this.downloadTimeoutMs = Number(options.downloadTimeoutMs || 10 * 60 * 1000);98
}100
async list(options) {101
const errors = [];102
const requestedChannel = String(options.channel || "alpha");103
const channels = requestedChannel === "all" ? ["alpha", "beta", "stable"] : [serverChannel(requestedChannel)];104
for (const baseUrl of this.baseUrls) {105
try {106
const pluginsByRelease = new Map();107
for (const selectedChannel of channels) {108
const url = new URL("api/plugins.php", baseUrl);109
url.searchParams.set("app_version", String(options.appVersion || ""));110
url.searchParams.set("plugin_api", String(options.pluginApiVersion || "1.0.0"));111
url.searchParams.set("channel", selectedChannel);112
const response = await this._fetchText(url, "application/json", MAX_CATALOG_BYTES);113
if (response.status !== 200) throw new Error(`Plugin server returned HTTP ${response.status}.`);114
let payload;115
try { payload = JSON.parse(response.text); } catch { throw new Error("Plugin server returned invalid JSON."); }116
const plugins = validatePluginCatalog(payload, { ...options, channel: selectedChannel });117
for (const plugin of plugins) {118
plugin.downloadUrl = this._resolveFileUrl(plugin.downloadUrl, baseUrl).toString();119
pluginsByRelease.set(plugin.releaseId, plugin);120
}121
}122
const plugins = [...pluginsByRelease.values()].sort((a, b) => a.id.localeCompare(b.id) || compareVersions(b.version, a.version) || Date.parse(b.publishedAt) - Date.parse(a.publishedAt));123
return { plugins, sourceBaseUrl: baseUrl };124
} catch (error) {125
errors.push({ baseUrl, message: error?.message || String(error) });126
this.logger?.warn("Plugin catalog endpoint failed.", errors.at(-1));127
}128
}129
const error = new Error("No configured plugin catalog endpoint is reachable.");130
error.code = "PLUGIN_CATALOG_UNREACHABLE";131
error.details = errors;132
throw error;133
}135
async downloadAndVerify(plugin) {136
if (!plugin || !Number.isSafeInteger(plugin.size) || plugin.size <= 0 || plugin.size > MAX_PLUGIN_BYTES) {137
throw new Error("Plugin package size is outside the allowed range.");138
}139
const urls = this._fileUrlCandidates(plugin.downloadUrl);140
const targetDir = path.join(this.downloadRoot, plugin.id, `${plugin.version}-${plugin.releaseId}`.replace(/[^A-Za-z0-9._-]/g, "-"));141
const destination = path.join(targetDir, path.basename(plugin.filename).replace(/[^A-Za-z0-9._-]/g, "-") || "plugin.chjplugin");142
fs.mkdirSync(targetDir, { recursive: true });143
if (fs.existsSync(destination)) {144
const stat = fs.statSync(destination);145
if (stat.isFile() && stat.size === plugin.size && await sha512File(destination) === plugin.sha512) {146
return { path: destination, size: stat.size, sha512: plugin.sha512, reused: true };147
}148
fs.unlinkSync(destination);149
}150
const failures = [];151
for (const url of urls) {152
const tempPath = `${destination}.${crypto.randomBytes(6).toString("hex")}.part`;153
const timeout = timeoutSignal(this.downloadTimeoutMs);154
try {155
const response = await this.fetch(url, { method: "GET", redirect: "manual", signal: timeout.signal, headers: { Accept: "application/octet-stream" } });156
if (response.status !== 200 || !response.body) throw new Error(`Plugin download returned HTTP ${response.status}.`);157
const declared = Number(response.headers.get("content-length") || 0);158
if (declared > 0 && declared !== plugin.size) throw integrityError("Plugin Content-Length does not match the catalog.");159
const hash = crypto.createHash("sha512");160
let received = 0;161
const verifier = new Transform({162
transform(chunk, _encoding, callback) {163
received += chunk.length;164
if (received > plugin.size || received > MAX_PLUGIN_BYTES) return callback(integrityError("Plugin package exceeds its catalog size."));165
hash.update(chunk);166
callback(null, chunk);167
}168
});169
await pipeline(Readable.fromWeb(response.body), verifier, fs.createWriteStream(tempPath, { flags: "wx", mode: 0o600 }));170
if (received !== plugin.size) throw integrityError("Plugin package size does not match the catalog.");171
const actualHash = hash.digest("hex");172
if (actualHash !== plugin.sha512) throw integrityError("Plugin package SHA-512 does not match the catalog.");173
fs.renameSync(tempPath, destination);174
return { path: destination, size: received, sha512: actualHash, reused: false, sourceUrl: url.toString() };175
} catch (error) {176
try { fs.unlinkSync(tempPath); } catch {}177
if (error?.code === "PLUGIN_ARTIFACT_INVALID") throw error;178
failures.push({ url: url.toString(), message: error?.message || String(error) });179
this.logger?.warn("Plugin download endpoint failed; trying fallback.", failures.at(-1));180
} finally {181
timeout.clear();182
}183
}184
const error = new Error("Plugin download failed on all configured endpoints.");185
error.code = "PLUGIN_DOWNLOAD_UNREACHABLE";186
error.details = failures;187
throw error;188
}190
cleanupDownloads() {191
return cleanDownloadCache(this.downloadRoot);192
}194
removeDownloadedPackage(packagePath) {195
return removeCachedFile(this.downloadRoot, packagePath);196
}198
_resolveFileUrl(value, sourceBaseUrl) {199
const resolved = new URL(String(value || "").replace(/^\/+/, ""), sourceBaseUrl).toString();200
return assertAllowedUrl(resolved, this.baseUrls, { pathSegment: "files/" });201
}203
_fileUrlCandidates(value) {204
const original = new URL(this._resolveFileUrl(value, this.baseUrls[0]));205
const sourceBase = this.baseUrls.map((baseUrl) => new URL(baseUrl)).find((base) => (206
base.origin === original.origin && original.pathname.startsWith(base.pathname)207
));208
if (!sourceBase) throw new Error("Plugin file URL is outside configured base paths.");209
const relative = `${original.pathname.slice(sourceBase.pathname.length)}${original.search}`;210
const candidates = [original];211
for (const baseUrl of this.baseUrls) {212
const candidate = new URL(relative, baseUrl);213
assertAllowedUrl(candidate.toString(), this.baseUrls, { pathSegment: "files/" });214
if (!candidates.some((item) => item.toString() === candidate.toString())) candidates.push(candidate);215
}216
return candidates;217
}219
async _fetchText(url, accept, maxBytes) {220
assertAllowedUrl(url.toString(), this.baseUrls, { pathSegment: "api/" });221
const timeout = timeoutSignal(this.requestTimeoutMs);222
try {223
const response = await this.fetch(url, { method: "GET", redirect: "manual", signal: timeout.signal, headers: { Accept: accept } });224
const declared = Number(response.headers.get("content-length") || 0);225
if (declared > maxBytes) throw new Error("Plugin catalog is too large.");226
const text = await response.text();227
if (Buffer.byteLength(text, "utf8") > maxBytes) throw new Error("Plugin catalog is too large.");228
return { status: response.status, text };229
} finally {230
timeout.clear();231
}232
}233
}235
module.exports = { MAX_CATALOG_BYTES, MAX_PLUGIN_BYTES, PluginCatalogProvider, validatePluginCatalog };SHA-256: b9f6a26c6025adb2766a4639356b32cd8281ffe470018d6cded491c6995cab8d
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0