CH-J Server ManagerSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Server Manager

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J Proprietary Software License 1.14

Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

11,4 KB · 235 ZeilenDatei herunterladen
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
6const { Readable, Transform } = require("node:stream");
7const { pipeline } = require("node:stream/promises");
8const { assertAllowedUrl, normalizeBaseUrl } = require("../security/urlPolicy");
9const { SHA512_PATTERN, serverChannel } = require("../../shared/updateContract");
10const { compareVersions, parseVersion } = require("../../shared/version");
11const { ALLOWED_PERMISSIONS, PLUGIN_ID_PATTERN } = require("./pluginRegistry");
12const { cleanDownloadCache, removeCachedFile } = require("../storage/downloadCache");
14const MAX_CATALOG_BYTES = 512 * 1024;
15const MAX_PLUGIN_BYTES = 512 * 1024 * 1024;
17function requiredString(value, field) {
18 const normalized = String(value || "").trim();
19 if (!normalized) throw new Error(`Missing ${field}.`);
20 return normalized;
23function 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 publishedAt
66 };
67 });
70function 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) };
77async 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");
83function integrityError(message) {
84 const error = new Error(message);
85 error.code = "PLUGIN_ARTIFACT_INVALID";
86 return error;
89class 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 }
235module.exports = { MAX_CATALOG_BYTES, MAX_PLUGIN_BYTES, PluginCatalogProvider, validatePluginCatalog };

SHA-256: b9f6a26c6025adb2766a4639356b32cd8281ffe470018d6cded491c6995cab8d

SHA-256 des Archivs: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0