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 fs = require("node:fs");4
const path = require("node:path");5
const { compareVersions, parseVersion } = require("../../shared/version");7
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{2,63}$/;8
const PLUGIN_API_VERSION = "1.2.0";9
const ALLOWED_PERMISSIONS = new Set([10
"session.read",11
"system.metrics.read",12
"keys.generate",13
"files.read",14
"files.write",15
"files.transfer",16
"users.read",17
"users.manage",18
"nginx.read",19
"nginx.manage",20
"certbot.manage",21
"mail.read",22
"mail.manage",23
"logs.read",24
"remote.exec",25
"local.hash"26
]);28
function validateManifest(input) {29
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Plugin manifest must be an object.");30
const id = String(input.id || "").trim();31
if (!PLUGIN_ID_PATTERN.test(id)) throw new Error("Invalid plugin ID.");32
const version = String(input.version || "").trim();33
parseVersion(version);34
const entry = String(input.entry || "").replace(/\\/g, "/");35
if (!entry || entry.startsWith("/") || entry.split("/").includes("..")) throw new Error("Invalid plugin entry path.");36
const permissions = Array.isArray(input.permissions) ? [...new Set(input.permissions.map(String))] : [];37
for (const permission of permissions) {38
if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`Unsupported plugin permission: ${permission}`);39
}40
const schemaVersion = Number(input.schemaVersion || 1);41
if (schemaVersion !== 1) throw new Error(`Unsupported plugin manifest schema: ${schemaVersion}`);42
const minAppVersion = String(input.minAppVersion || "0.0.1");43
parseVersion(minAppVersion);44
const pluginApi = String(input.pluginApi || "^1.0.0");45
if (!/^\^1\.\d+\.\d+$/.test(pluginApi)) throw new Error(`Unsupported plugin API: ${pluginApi}`);46
return {47
schemaVersion,48
id,49
name: String(input.name || id).trim().slice(0, 100),50
description: String(input.description || "").trim().slice(0, 500),51
version,52
publisher: String(input.publisher || "Unknown").trim().slice(0, 100),53
entry,54
pluginApi,55
minAppVersion,56
permissions57
};58
}60
function supportsPluginApi(range, supportedVersion = PLUGIN_API_VERSION) {61
const required = String(range || "").match(/^\^1\.(\d+)\.(\d+)$/);62
const supported = String(supportedVersion || "").match(/^1\.(\d+)\.(\d+)$/);63
return Boolean(required && supported && (Number(required[1]) < Number(supported[1]) || (Number(required[1]) === Number(supported[1]) && Number(required[2]) <= Number(supported[2]))));64
}66
class PluginRegistry {67
constructor(rootDir, logger, options = {}) {68
this.rootDir = path.join(rootDir, "plugins");69
this.bundledRoot = options.bundledRoot || null;70
this.logger = logger;71
this.locations = new Map();72
}74
listInstalled() {75
fs.mkdirSync(this.rootDir, { recursive: true });76
this.locations.clear();77
const plugins = [];78
const roots = [{ root: this.rootDir, bundled: false }];79
if (this.bundledRoot && fs.statSync(this.bundledRoot, { throwIfNoEntry: false })?.isDirectory()) roots.push({ root: this.bundledRoot, bundled: true });80
const candidatesById = new Map();81
for (const source of roots) for (const idEntry of fs.readdirSync(source.root, { withFileTypes: true })) {82
if (!idEntry.isDirectory() || !PLUGIN_ID_PATTERN.test(idEntry.name)) continue;83
const idRoot = path.join(source.root, idEntry.name);84
const candidates = [];85
for (const versionEntry of fs.readdirSync(idRoot, { withFileTypes: true })) {86
if (!versionEntry.isDirectory() || versionEntry.name.startsWith(".")) continue;87
const manifestPath = path.join(idRoot, versionEntry.name, "manifest.json");88
try {89
const manifest = validateManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));90
if (manifest.id !== idEntry.name || manifest.version !== versionEntry.name) {91
throw new Error("Plugin directory does not match its manifest.");92
}93
const entryPath = path.join(idRoot, versionEntry.name, manifest.entry);94
if (!fs.statSync(entryPath).isFile()) throw new Error("Plugin entry file is missing.");95
let installation = {};96
try { installation = JSON.parse(fs.readFileSync(path.join(idRoot, versionEntry.name, ".installation.json"), "utf8")); } catch {}97
candidates.push({98
...manifest,99
bundled: source.bundled,100
installedReleaseId: typeof installation.releaseId === "string" ? installation.releaseId : null,101
installedSha512: /^[a-f0-9]{128}$/i.test(String(installation.sha512 || "")) ? String(installation.sha512).toLowerCase() : null,102
installedAt: typeof installation.installedAt === "string" ? installation.installedAt : null,103
installedPublishedAt: typeof installation.publishedAt === "string" ? installation.publishedAt : null,104
_root: path.join(idRoot, versionEntry.name)105
});106
} catch (error) {107
this.logger?.warn("Ignoring invalid installed plugin.", {108
pluginId: idEntry.name,109
version: versionEntry.name,110
message: error?.message || String(error)111
});112
}113
}114
if (!candidatesById.has(idEntry.name)) candidatesById.set(idEntry.name, []);115
candidatesById.get(idEntry.name).push(...candidates);116
}117
for (const candidates of candidatesById.values()) {118
candidates.sort((left, right) => compareVersions(right.version, left.version) || Number(left.bundled) - Number(right.bundled));119
if (!candidates[0]) continue;120
const selected = candidates[0];121
this.locations.set(`${selected.id}@${selected.version}`, selected._root);122
const { _root, ...publicManifest } = selected;123
plugins.push(publicManifest);124
}125
return plugins.sort((left, right) => left.name.localeCompare(right.name));126
}128
getInstalled(pluginId) {129
const id = String(pluginId || "");130
if (!PLUGIN_ID_PATTERN.test(id)) throw new Error("Invalid plugin ID.");131
return this.listInstalled().find((plugin) => plugin.id === id) || null;132
}134
resolveEntry(pluginId) {135
const manifest = this.getInstalled(pluginId);136
if (!manifest) throw new Error("Plugin is not installed.");137
const root = this.locations.get(`${manifest.id}@${manifest.version}`) || path.join(this.rootDir, manifest.id, manifest.version);138
return { manifest, root, entryPath: path.join(root, ...manifest.entry.split("/")) };139
}141
removeInstalled(pluginId) {142
const id = String(pluginId || "");143
if (!PLUGIN_ID_PATTERN.test(id)) throw new Error("Invalid plugin ID.");144
const directory = path.join(this.rootDir, id);145
if (!fs.existsSync(directory)) return { removed: false, pluginId: id };146
fs.rmSync(directory, { recursive: true, force: true });147
this.logger?.info("Plugin removed.", { pluginId: id });148
return { removed: true, pluginId: id };149
}150
}152
module.exports = {153
ALLOWED_PERMISSIONS,154
PLUGIN_API_VERSION,155
PLUGIN_ID_PATTERN,156
PluginRegistry,157
supportsPluginApi,158
validateManifest159
};SHA-256: 8de632e09ca2511335ae7798b6e21d04865e275ae1f26a23a845704fd4666289
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0