build hook plugins

This commit is contained in:
KingKDot
2026-06-22 12:54:12 -05:00
parent 6180e7f1a2
commit 7aaf115e2f
20 changed files with 1857 additions and 81 deletions
@@ -21,7 +21,7 @@ import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import { canUploadFiles } from "../../users";
import { canUploadFiles, canUserAccessPlugin } from "../../users";
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
import path from "path";
import fs from "fs";
@@ -37,8 +37,93 @@ type BuildRouteDeps = {
startBuildProcess: (buildId: string, config: any) => Promise<void>;
sanitizeMutex: (value?: string) => string | undefined;
allowedPlatforms: Set<string>;
listPluginManifests?: () => Promise<any[]>;
};
function getPathValue(source: any, dotted: string): any {
if (!source || !dotted) return undefined;
return dotted.split(".").reduce((cur, key) => (cur && typeof cur === "object" ? cur[key] : undefined), source);
}
function requirementMet(req: any, buildConfig: any, pluginSettings: any): boolean {
if (!req || typeof req !== "object") return true;
let value: any;
if (typeof req.field === "string") value = getPathValue(buildConfig, req.field);
else if (typeof req.pluginSetting === "string") value = getPathValue(pluginSettings, req.pluginSetting);
else if (Array.isArray(req.platforms)) {
const platforms = Array.isArray(buildConfig.platforms) ? buildConfig.platforms : [];
return req.platforms.some((p: any) => typeof p === "string" && platforms.includes(p));
} else {
return true;
}
if (req.truthy === true && !value) return false;
if (req.falsy === true && value) return false;
if (Object.prototype.hasOwnProperty.call(req, "equals") && value !== req.equals) return false;
if (Object.prototype.hasOwnProperty.call(req, "notEquals") && value === req.notEquals) return false;
if (Object.prototype.hasOwnProperty.call(req, "includes")) {
if (!Array.isArray(value) || !value.includes(req.includes)) return false;
}
return true;
}
function sanitizePluginBuildValue(setting: any, value: any): any {
const type = typeof setting?.type === "string" ? setting.type : "string";
if (value === undefined || value === null || value === "") {
return setting?.default !== undefined ? setting.default : undefined;
}
if (type === "boolean") return value === true || value === "true" || value === "1";
if (type === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return setting?.default !== undefined ? setting.default : undefined;
const min = typeof setting.min === "number" ? setting.min : -1_000_000_000;
const max = typeof setting.max === "number" ? setting.max : 1_000_000_000;
return Math.max(min, Math.min(max, n));
}
const str = String(value).slice(0, type === "textarea" ? 10_000 : 1_000);
if (type === "select" && Array.isArray(setting.options)) {
const allowed = new Set(setting.options.map((opt: any) => typeof opt === "string" ? opt : opt?.value).filter((v: any) => typeof v === "string"));
return allowed.has(str) ? str : setting.default;
}
return str;
}
async function sanitizeBuildPlugins(
raw: any,
deps: BuildRouteDeps,
user: any,
buildConfig: any,
): Promise<{ value: Record<string, any>; error?: string }> {
if (!raw || typeof raw !== "object" || !deps.listPluginManifests) return { value: {} };
const manifests = await deps.listPluginManifests();
const byId = new Map(manifests.map((m) => [m.id, m]));
const value: Record<string, any> = {};
for (const [pluginId, rawPlugin] of Object.entries(raw)) {
if (!/^[A-Za-z0-9._-]{1,128}$/.test(pluginId)) continue;
const manifest = byId.get(pluginId);
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
const rawRecord = rawPlugin && typeof rawPlugin === "object" ? rawPlugin as any : {};
const enabled = rawRecord.enabled !== false;
const settings: Record<string, any> = {};
for (const setting of Array.isArray(manifest.build.settings) ? manifest.build.settings : []) {
if (!setting || typeof setting.key !== "string") continue;
const sanitized = sanitizePluginBuildValue(setting, rawRecord.settings?.[setting.key]);
if (enabled && setting.required && (sanitized === undefined || sanitized === "")) {
return { value: {}, error: `Build plugin ${manifest.name || pluginId} requires ${setting.label || setting.key}` };
}
if (sanitized !== undefined) settings[setting.key] = sanitized;
}
value[pluginId] = { enabled, settings };
if (enabled) {
for (const req of Array.isArray(manifest.build.requires) ? manifest.build.requires : []) {
if (!requirementMet(req, buildConfig, settings)) {
return { value: {}, error: req.message || `Build plugin ${manifest.name || pluginId} requirements are not met` };
}
}
}
}
return { value };
}
export async function handleBuildRoutes(
req: Request,
url: URL,
@@ -120,6 +205,7 @@ export async function handleBuildRoutes(
outputSgnTxt,
fetchPublicIP,
uploadToFileShare,
buildPlugins,
} = body;
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
@@ -377,7 +463,7 @@ export async function handleBuildRoutes(
const rateLimitActive = getConfig().registration.mode !== "off";
if (rateLimitActive) recordBuildStart(user.userId);
deps.startBuildProcess(buildId, {
const safeBuildConfig: any = {
platforms: allowedPlatforms,
serverUrl: safeServerUrl,
rawServerList: safeRawServerList,
@@ -424,13 +510,42 @@ export async function handleBuildRoutes(
outputSgnTxt: safeOutputSgnTxt,
fetchPublicIP: !!fetchPublicIP,
uploadToFileShare: safeUploadToFileShare,
}).finally(() => {
};
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
if (pluginBuildResult.error) {
if (rateLimitActive) recordBuildEnd(user.userId);
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
}
safeBuildConfig.buildPlugins = pluginBuildResult.value;
deps.startBuildProcess(buildId, safeBuildConfig).finally(() => {
if (rateLimitActive) recordBuildEnd(user.userId);
});
return Response.json({ buildId });
}
if (req.method === "GET" && url.pathname === "/api/build/plugins") {
requirePermission(user, "clients:build");
if (!deps.listPluginManifests) {
return Response.json({ plugins: [] });
}
const manifests = await deps.listPluginManifests();
const plugins = manifests
.filter((manifest) => manifest?.build && manifest?.hasServer === true)
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
.map((manifest) => ({
id: manifest.id,
name: manifest.name || manifest.id,
runtime: manifest.runtime || "native",
hasServer: manifest.hasServer === true,
enabled: manifest.enabled !== false,
build: manifest.build,
}));
return Response.json({ plugins });
}
if (req.method === "GET" && url.pathname === "/api/build/list") {
requirePermission(user, "clients:build");
@@ -164,21 +164,25 @@ export async function handlePluginRoutes(
const plugins = await deps.listPluginManifests();
const enriched = plugins
.filter((p) => canUserAccessPlugin(user.userId, user.role, p.id))
.map((p) => ({
...p,
enabled: deps.pluginState.enabled[p.id] !== false,
lastError: deps.pluginState.lastError[p.id] || "",
autoLoad: deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false },
runtime: p.runtime || "native",
apiVersion: p.apiVersion || 1,
needs: p.needs || {},
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
serverRunning: deps.pluginRuntime.isRunning(p.id),
}));
.map((p) => {
const runtime = p.runtime || "native";
const isServerOnly = runtime === "server";
return {
...p,
enabled: deps.pluginState.enabled[p.id] !== false,
lastError: deps.pluginState.lastError[p.id] || "",
autoLoad: isServerOnly ? false : deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: isServerOnly ? [] : deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false },
runtime,
apiVersion: p.apiVersion || 1,
needs: p.needs || {},
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
serverRunning: deps.pluginRuntime.isRunning(p.id),
};
});
return Response.json({ plugins: enriched });
}
@@ -261,6 +265,7 @@ export async function handlePluginRoutes(
const manifests = await deps.listPluginManifests();
const plugins = manifests
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
.filter((manifest) => manifest.runtime !== "server")
.map((manifest) => ({
id: manifest.id,
name: manifest.name || manifest.id,
@@ -451,6 +456,17 @@ export async function handlePluginRoutes(
body = await req.json();
} catch {}
const autoLoad = !!body.autoLoad;
const manifest = await loadManifest(pluginId);
if (manifest.runtime === "server") {
delete deps.pluginState.autoLoad[pluginId];
delete deps.pluginState.autoStartEvents[pluginId];
await deps.savePluginState();
return Response.json(
{ ok: false, error: "Server-side plugins do not support client auto-load" },
{ status: 400 },
);
}
if (autoLoad && deps.pluginState.enabled[pluginId] === false) {
return Response.json(
@@ -868,6 +884,13 @@ export async function handlePluginRoutes(
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const manifest = await loadManifest(pluginId);
if (manifest.runtime === "server") {
return Response.json(
{ ok: false, error: "Server-side plugins do not load onto clients" },
{ status: 400 },
);
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
if (deps.isPluginLoaded(targetId, pluginId)) {