Files
pleskSaas/lib/plesk/client.ts

259 lines
6.2 KiB
TypeScript

import { decryptPleskSecret } from "@/lib/crypto";
export type PleskAuthType = "api_key" | "basic";
export type PleskConnection = {
baseUrl: string;
authType: PleskAuthType;
encryptedSecret: string;
};
export type PleskSubscription = {
plesk_subscription_id?: string;
id?: string | number;
guid?: string;
subscription_id?: string | number;
external_id?: string;
name?: string;
status?: string;
owner_login?: string;
owner?: { login?: string };
plan_name?: string;
service_plan?: { name?: string };
};
export type PleskDomain = {
id?: string | number;
guid?: string;
name?: string;
domain_name?: string;
domain?: string;
status?: string;
subscription?: { id?: string | number };
subscription_id?: string | number;
webspace_id?: string | number;
};
type PleskCliResult = {
code?: number;
stdout?: string;
stderr?: string;
};
const DEFAULT_TIMEOUT_MS = 12_000;
function normalizeBaseUrl(baseUrl: string) {
return baseUrl.trim().replace(/\/+$/, "");
}
function buildHeaders(
authType: PleskAuthType,
plainSecret: string,
): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (authType === "api_key") {
headers["X-API-Key"] = plainSecret;
} else {
headers.Authorization = `Basic ${Buffer.from(plainSecret).toString("base64")}`;
}
return headers;
}
async function withTimeout(
input: RequestInfo | URL,
init: RequestInit,
timeoutMs = DEFAULT_TIMEOUT_MS,
) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, {
...init,
signal: controller.signal,
cache: "no-store",
});
} finally {
clearTimeout(timeout);
}
}
export async function pleskRequest<T>(
connection: PleskConnection,
path: string,
init: RequestInit = {},
retries = 1,
) {
const base = normalizeBaseUrl(connection.baseUrl);
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
const secret = decryptPleskSecret(connection.encryptedSecret);
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const res = await withTimeout(url, {
...init,
headers: {
...buildHeaders(connection.authType, secret),
...(init.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Plesk request failed (${res.status}): ${text || "unknown error"}`,
);
}
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return null as T;
}
return (await res.json()) as T;
} catch (error) {
lastError =
error instanceof Error ? error : new Error("Plesk request failed");
if (attempt === retries) break;
await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
}
}
throw lastError ?? new Error("Plesk request failed");
}
export async function testPleskConnection(connection: PleskConnection) {
try {
await pleskRequest(connection, "/api/v2/server");
return { ok: true as const };
} catch {
await pleskRequest(connection, "/api/v2/subscriptions?limit=1");
return { ok: true as const };
}
}
export async function listPleskSubscriptions(connection: PleskConnection) {
const listResult = await pleskCliCall(connection, "subscription", ["--list"]);
const subscriptionNames = (listResult.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const subscriptions: PleskSubscription[] = [];
for (const subscriptionName of subscriptionNames.slice(0, 500)) {
let status: "active" | "suspended" | "unknown" = "unknown";
try {
const infoResult = await pleskCliCall(connection, "subscription", [
"--info",
subscriptionName,
]);
status = parseSubscriptionStatusFromInfo(infoResult.stdout ?? "");
} catch {
status = "unknown";
}
subscriptions.push({
plesk_subscription_id: subscriptionName,
subscription_id: subscriptionName,
name: subscriptionName,
status,
});
}
return subscriptions;
}
export function parseSubscriptionStatusFromInfo(
stdout: string,
): "active" | "suspended" | "unknown" {
const line = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => /^domain\s+status\s*:/i.test(entry));
if (!line) return "unknown";
const value = line
.replace(/^domain\s+status\s*:/i, "")
.trim()
.toLowerCase();
if (value.includes("ok")) return "active";
if (value.includes("suspend") || value.includes("disabled")) {
return "suspended";
}
return "unknown";
}
export async function pleskCliCall(
connection: PleskConnection,
command: string,
params: string[],
) {
const payload = await pleskRequest<PleskCliResult>(
connection,
`/api/v2/cli/${command}/call`,
{
method: "POST",
body: JSON.stringify({ params }),
},
);
const code = payload?.code ?? -1;
const stdout = payload?.stdout ?? "";
const stderr = payload?.stderr ?? "";
if (code !== 0) {
throw new Error(
`Plesk CLI '${command}' failed (code ${code}): ${stderr || "unknown error"}${stdout ? `\n${stdout}` : ""}`,
);
}
return { code, stdout, stderr };
}
export async function listPleskDomains(connection: PleskConnection) {
const all: PleskDomain[] = [];
for (let page = 1; page <= 20; page += 1) {
const payload = await pleskRequest<PleskDomain[]>(
connection,
`/api/v2/domains?page=${page}&per_page=100`,
);
const rows = Array.isArray(payload) ? payload : [];
all.push(...rows);
if (rows.length < 100) break;
}
return all;
}
export async function suspendPleskSubscription(
connection: PleskConnection,
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-off",
subscriptionName,
]);
}
export async function unsuspendPleskSubscription(
connection: PleskConnection,
subscriptionName: string,
) {
return pleskCliCall(connection, "subscription", [
"--webspace-on",
subscriptionName,
]);
}