Initial working state: CL3.5 complete (Plesk sync + suspend/unsuspend)
This commit is contained in:
139
lib/agency.ts
Normal file
139
lib/agency.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { Database } from "@/lib/types";
|
||||
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
|
||||
export type AgencyContext = {
|
||||
userId: string;
|
||||
agencyId: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
};
|
||||
|
||||
export function assertAgencyAdmin(context: AgencyContext) {
|
||||
if (context.role !== "owner" && context.role !== "admin") {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
type AgencyMemberSummary = Pick<
|
||||
Database["public"]["Tables"]["agency_members"]["Row"],
|
||||
"agency_id" | "role"
|
||||
>;
|
||||
|
||||
async function getOrCreateAgencyMembership(
|
||||
userId: string,
|
||||
email?: string | null,
|
||||
) {
|
||||
const supabase = createSupabaseServerClient() as any;
|
||||
const admin = createSupabaseAdminClient() as any;
|
||||
|
||||
const { data: existingMembership } = (await supabase
|
||||
.from("agency_members")
|
||||
.select("agency_id, role")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()) as { data: AgencyMemberSummary | null };
|
||||
|
||||
if (existingMembership) {
|
||||
return existingMembership;
|
||||
}
|
||||
|
||||
const defaultAgencyName = email
|
||||
? `${email.split("@")[0] ?? "New"} Agency`
|
||||
: "New Agency";
|
||||
|
||||
const { data: createdAgency, error: agencyError } = (await admin
|
||||
.from("agencies")
|
||||
.insert({ name: defaultAgencyName })
|
||||
.select("id")
|
||||
.single()) as {
|
||||
data: Pick<Database["public"]["Tables"]["agencies"]["Row"], "id"> | null;
|
||||
error: { message: string } | null;
|
||||
};
|
||||
|
||||
if (agencyError || !createdAgency) {
|
||||
throw new Error(
|
||||
`Unable to create agency: ${agencyError?.message ?? "no row returned"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { error: membershipError } = await admin.from("agency_members").insert({
|
||||
agency_id: createdAgency.id,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
if (!membershipError) {
|
||||
return {
|
||||
agency_id: createdAgency.id,
|
||||
role: "owner" as const,
|
||||
};
|
||||
}
|
||||
|
||||
// If another request created membership first, fall back to a fresh read.
|
||||
const { data: retriedMembership } = (await supabase
|
||||
.from("agency_members")
|
||||
.select("agency_id, role")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()) as { data: AgencyMemberSummary | null };
|
||||
|
||||
if (retriedMembership) {
|
||||
return retriedMembership;
|
||||
}
|
||||
|
||||
throw new Error("Unable to create agency membership");
|
||||
}
|
||||
|
||||
export async function getServerAgencyContextOrRedirect(): Promise<AgencyContext> {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const membership = await getOrCreateAgencyMembership(user.id, user.email);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
agencyId: membership.agency_id,
|
||||
role: membership.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRouteAgencyContextOrThrow(): Promise<AgencyContext> {
|
||||
const supabase = createSupabaseRouteClient();
|
||||
const db = supabase as any;
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const { data: membership } = (await db
|
||||
.from("agency_members")
|
||||
.select("agency_id, role")
|
||||
.eq("user_id", session.user.id)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()) as { data: AgencyMemberSummary | null };
|
||||
|
||||
if (!membership) {
|
||||
throw new Error("No agency membership");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.user.id,
|
||||
agencyId: membership.agency_id,
|
||||
role: membership.role,
|
||||
};
|
||||
}
|
||||
18
lib/api/rate-limit.ts
Normal file
18
lib/api/rate-limit.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const buckets = new Map<string, number[]>();
|
||||
|
||||
export function assertRateLimit(
|
||||
key: string,
|
||||
maxRequests: number,
|
||||
windowMs: number,
|
||||
) {
|
||||
const now = Date.now();
|
||||
const start = now - windowMs;
|
||||
const hits = (buckets.get(key) ?? []).filter((ts) => ts >= start);
|
||||
|
||||
if (hits.length >= maxRequests) {
|
||||
throw new Error("Rate limit exceeded. Please try again in a minute.");
|
||||
}
|
||||
|
||||
hits.push(now);
|
||||
buckets.set(key, hits);
|
||||
}
|
||||
19
lib/api/security.ts
Normal file
19
lib/api/security.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export function assertSameOrigin(request: Request) {
|
||||
const origin = request.headers.get("origin");
|
||||
const host =
|
||||
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||
|
||||
if (!origin || !host) {
|
||||
throw new Error("Invalid request origin");
|
||||
}
|
||||
|
||||
const originUrl = new URL(origin);
|
||||
const appUrl = new URL(env.appUrl);
|
||||
|
||||
const allowedHosts = new Set([appUrl.host, host]);
|
||||
if (!allowedHosts.has(originUrl.host)) {
|
||||
throw new Error("Cross-site POST blocked");
|
||||
}
|
||||
}
|
||||
116
lib/crypto.ts
Normal file
116
lib/crypto.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "crypto";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
|
||||
function deriveKey(secret: string) {
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
export function encryptSecret(value: string) {
|
||||
if (!env.encryptionKey) {
|
||||
throw new Error("ENCRYPTION_KEY is not configured");
|
||||
}
|
||||
|
||||
const iv = randomBytes(12);
|
||||
const key = deriveKey(env.encryptionKey);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string) {
|
||||
if (!env.encryptionKey) {
|
||||
throw new Error("ENCRYPTION_KEY is not configured");
|
||||
}
|
||||
|
||||
const [ivEncoded, tagEncoded, valueEncoded] = payload.split(":");
|
||||
if (!ivEncoded || !tagEncoded || !valueEncoded) {
|
||||
throw new Error("Invalid encrypted payload");
|
||||
}
|
||||
|
||||
const key = deriveKey(env.encryptionKey);
|
||||
const decipher = createDecipheriv(
|
||||
ALGO,
|
||||
key,
|
||||
Buffer.from(ivEncoded, "base64"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagEncoded, "base64"));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(valueEncoded, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
|
||||
function derivePleskKey() {
|
||||
if (!env.pleskCredEncKey) {
|
||||
throw new Error("PLESK_CRED_ENC_KEY is not configured");
|
||||
}
|
||||
|
||||
const raw = env.pleskCredEncKey.trim();
|
||||
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, "hex");
|
||||
}
|
||||
|
||||
const b64 = Buffer.from(raw, "base64");
|
||||
if (b64.length === 32) {
|
||||
return b64;
|
||||
}
|
||||
|
||||
throw new Error("PLESK_CRED_ENC_KEY must be 32-byte base64 or 64-char hex");
|
||||
}
|
||||
|
||||
export function encryptPleskSecret(value: string, keyId = "v1") {
|
||||
const iv = randomBytes(12);
|
||||
const key = derivePleskKey();
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
encryptedSecret: `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`,
|
||||
secretKid: keyId,
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptPleskSecret(payload: string) {
|
||||
const [ivEncoded, tagEncoded, valueEncoded] = payload.split(":");
|
||||
if (!ivEncoded || !tagEncoded || !valueEncoded) {
|
||||
throw new Error("Invalid encrypted payload");
|
||||
}
|
||||
|
||||
const key = derivePleskKey();
|
||||
const decipher = createDecipheriv(
|
||||
ALGO,
|
||||
key,
|
||||
Buffer.from(ivEncoded, "base64"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagEncoded, "base64"));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(valueEncoded, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
25
lib/env.ts
Normal file
25
lib/env.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const requiredEnvs = [
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
] as const;
|
||||
|
||||
for (const key of requiredEnvs) {
|
||||
if (!process.env[key]) {
|
||||
// Keep as runtime warning for smoother local setup.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[env] Missing ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const env = {
|
||||
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||
jobSecret: process.env.JOB_SECRET ?? "",
|
||||
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
||||
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
|
||||
stripePriceId: process.env.STRIPE_PRICE_ID ?? "",
|
||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
|
||||
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
|
||||
supabaseServiceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
||||
};
|
||||
93
lib/plesk.ts
Normal file
93
lib/plesk.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { decryptSecret } from "@/lib/crypto";
|
||||
|
||||
export type PleskAuthType = "api_key" | "basic" | "oauth_token";
|
||||
|
||||
export type PleskConnection = {
|
||||
baseUrl: string;
|
||||
authType: PleskAuthType;
|
||||
authSecretEncrypted: string;
|
||||
};
|
||||
|
||||
type PleskRequestOptions = {
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
function buildHeaders(authType: PleskAuthType, authSecret: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (authType === "api_key") {
|
||||
headers["X-API-Key"] = authSecret;
|
||||
} else if (authType === "oauth_token") {
|
||||
headers.Authorization = `Bearer ${authSecret}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(authSecret).toString("base64")}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string) {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export async function pleskRequest<T>(
|
||||
connection: PleskConnection,
|
||||
path: string,
|
||||
options: PleskRequestOptions = {},
|
||||
) {
|
||||
const authSecret = decryptSecret(connection.authSecretEncrypted);
|
||||
const response = await fetch(joinUrl(connection.baseUrl, path), {
|
||||
method: options.method ?? "GET",
|
||||
headers: buildHeaders(connection.authType, authSecret),
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(`Plesk request failed (${response.status}): ${message}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function validatePleskConnection(connection: PleskConnection) {
|
||||
try {
|
||||
await pleskRequest(connection, "/api/v2/server");
|
||||
return true;
|
||||
} catch {
|
||||
await pleskRequest(connection, "/api/v2/subscriptions");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPleskSubscriptions(connection: PleskConnection): Promise<any[]> {
|
||||
const result = await pleskRequest<any[]>(connection, "/api/v2/subscriptions");
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function listPleskDomains(connection: PleskConnection): Promise<any[]> {
|
||||
const result = await pleskRequest<any[]>(connection, "/api/v2/domains");
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function suspendPleskSubscription(connection: PleskConnection, subscriptionId: string) {
|
||||
return pleskRequest(connection, `/api/v2/subscriptions/${subscriptionId}/suspend`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function unsuspendPleskSubscription(connection: PleskConnection, subscriptionId: string) {
|
||||
return pleskRequest(connection, `/api/v2/subscriptions/${subscriptionId}/unsuspend`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
248
lib/plesk/client.ts
Normal file
248
lib/plesk/client.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { decryptPleskSecret } from "@/lib/crypto";
|
||||
|
||||
export type PleskAuthType = "api_key" | "basic";
|
||||
|
||||
export type PleskConnection = {
|
||||
baseUrl: string;
|
||||
authType: PleskAuthType;
|
||||
encryptedSecret: string;
|
||||
};
|
||||
|
||||
export type PleskSubscription = {
|
||||
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) {
|
||||
try {
|
||||
const all: PleskSubscription[] = [];
|
||||
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const payload = await pleskRequest<PleskSubscription[]>(
|
||||
connection,
|
||||
`/api/v2/subscriptions?page=${page}&per_page=100`,
|
||||
);
|
||||
const rows = Array.isArray(payload) ? payload : [];
|
||||
all.push(...rows);
|
||||
if (rows.length < 100) break;
|
||||
}
|
||||
|
||||
return all;
|
||||
} catch {
|
||||
const cli = await pleskRequest<PleskCliResult>(
|
||||
connection,
|
||||
"/api/v2/cli/subscription/call",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ params: ["--list"] }),
|
||||
},
|
||||
);
|
||||
|
||||
const stdout = cli?.stdout ?? "";
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const columns = line.split(/\s+/);
|
||||
const externalId = columns[0] ?? line;
|
||||
return {
|
||||
id: externalId,
|
||||
subscription_id: externalId,
|
||||
external_id: externalId,
|
||||
name: columns.slice(1).join(" ") || externalId,
|
||||
status: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
218
lib/plesk/sync.ts
Normal file
218
lib/plesk/sync.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Database } from "@/lib/types";
|
||||
import { listPleskDomains, listPleskSubscriptions } from "@/lib/plesk/client";
|
||||
|
||||
type SupabaseLike = any;
|
||||
|
||||
type SyncResult = {
|
||||
instanceId: string;
|
||||
agencyId: string;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
subscriptionsUpserted: number;
|
||||
domainsUpserted: number;
|
||||
};
|
||||
|
||||
type InstanceRow = Pick<
|
||||
Database["public"]["Tables"]["plesk_instances"]["Row"],
|
||||
| "id"
|
||||
| "agency_id"
|
||||
| "base_url"
|
||||
| "auth_type"
|
||||
| "encrypted_secret"
|
||||
| "last_connected_at"
|
||||
>;
|
||||
|
||||
const MIN_INTERVAL_MS = 3 * 60 * 1000;
|
||||
|
||||
export async function syncPleskInstance(
|
||||
supabase: SupabaseLike,
|
||||
instance: InstanceRow,
|
||||
actorUserId?: string | null,
|
||||
action = "sync",
|
||||
): Promise<SyncResult> {
|
||||
const startedAtIso = new Date().toISOString();
|
||||
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
status: "syncing",
|
||||
last_sync_error: null,
|
||||
})
|
||||
.eq("id", instance.id)
|
||||
.eq("agency_id", instance.agency_id);
|
||||
|
||||
const last = instance.last_connected_at
|
||||
? new Date(instance.last_connected_at).getTime()
|
||||
: 0;
|
||||
if (last && Date.now() - last < MIN_INTERVAL_MS) {
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
status: "connected",
|
||||
last_sync_at: startedAtIso,
|
||||
last_sync_status: "skipped",
|
||||
last_sync_error: "recently_synced",
|
||||
})
|
||||
.eq("id", instance.id)
|
||||
.eq("agency_id", instance.agency_id);
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
agencyId: instance.agency_id,
|
||||
skipped: true,
|
||||
reason: "recently_synced",
|
||||
subscriptionsUpserted: 0,
|
||||
domainsUpserted: 0,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [subscriptionsRaw, domainsRaw] = await Promise.all([
|
||||
listPleskSubscriptions({
|
||||
baseUrl: instance.base_url,
|
||||
authType: instance.auth_type,
|
||||
encryptedSecret: instance.encrypted_secret,
|
||||
}),
|
||||
listPleskDomains({
|
||||
baseUrl: instance.base_url,
|
||||
authType: instance.auth_type,
|
||||
encryptedSecret: instance.encrypted_secret,
|
||||
}),
|
||||
]);
|
||||
|
||||
const subscriptionsForUpsert = subscriptionsRaw
|
||||
.map((item: any) => {
|
||||
const sourceId = item?.id ?? item?.guid ?? item?.subscription_id;
|
||||
if (!sourceId) return null;
|
||||
return {
|
||||
agency_id: instance.agency_id,
|
||||
plesk_instance_id: instance.id,
|
||||
plesk_subscription_id: String(sourceId),
|
||||
name: item?.name ? String(item.name) : null,
|
||||
status: item?.status ? String(item.status) : null,
|
||||
owner_login: item?.owner_login
|
||||
? String(item.owner_login)
|
||||
: item?.owner?.login
|
||||
? String(item.owner.login)
|
||||
: null,
|
||||
plan_name: item?.plan_name
|
||||
? String(item.plan_name)
|
||||
: item?.service_plan?.name
|
||||
? String(item.service_plan.name)
|
||||
: null,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
|
||||
if (subscriptionsForUpsert.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from("plesk_subscriptions")
|
||||
.upsert(subscriptionsForUpsert, {
|
||||
onConflict: "agency_id,plesk_subscription_id",
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
const domainsForUpsert = domainsRaw
|
||||
.map((item: any) => {
|
||||
const domainId = item?.id ?? item?.guid ?? item?.name;
|
||||
const domainName = item?.name ?? item?.domain_name ?? item?.domain;
|
||||
const externalSubscriptionId =
|
||||
item?.subscription?.id ?? item?.subscription_id ?? item?.webspace_id;
|
||||
const rawCreated = item?.created_at ?? item?.created;
|
||||
const sourceCreatedAt = rawCreated
|
||||
? new Date(String(rawCreated)).toISOString()
|
||||
: null;
|
||||
const aliasesCount =
|
||||
typeof item?.aliases_count === "number"
|
||||
? item.aliases_count
|
||||
: Array.isArray(item?.aliases)
|
||||
? item.aliases.length
|
||||
: null;
|
||||
|
||||
if (!domainId || !domainName) return null;
|
||||
|
||||
return {
|
||||
agency_id: instance.agency_id,
|
||||
plesk_instance_id: instance.id,
|
||||
subscription_id: null,
|
||||
external_subscription_id: externalSubscriptionId
|
||||
? String(externalSubscriptionId)
|
||||
: null,
|
||||
plesk_domain_id: String(domainId),
|
||||
domain_name: String(domainName),
|
||||
status: item?.status ? String(item.status) : null,
|
||||
hosting_type: item?.hosting_type
|
||||
? String(item.hosting_type)
|
||||
: item?.hosting?.type
|
||||
? String(item.hosting.type)
|
||||
: null,
|
||||
source_created_at: sourceCreatedAt,
|
||||
aliases_count: aliasesCount,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
|
||||
if (domainsForUpsert.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from("plesk_domains")
|
||||
.upsert(domainsForUpsert, {
|
||||
onConflict: "agency_id,plesk_domain_id",
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
last_connected_at: new Date().toISOString(),
|
||||
status: "connected",
|
||||
error_message: null,
|
||||
last_sync_at: startedAtIso,
|
||||
last_sync_status: "success",
|
||||
last_sync_error: null,
|
||||
last_sync_subscriptions: subscriptionsForUpsert.length,
|
||||
last_sync_domains: domainsForUpsert.length,
|
||||
})
|
||||
.eq("id", instance.id)
|
||||
.eq("agency_id", instance.agency_id);
|
||||
|
||||
await supabase.from("actions_log").insert({
|
||||
agency_id: instance.agency_id,
|
||||
actor_user_id: actorUserId ?? null,
|
||||
target_type: "plesk_instance",
|
||||
target_id: instance.id,
|
||||
action,
|
||||
status: "success",
|
||||
metadata: {
|
||||
subscriptions_seen: subscriptionsRaw.length,
|
||||
domains_seen: domainsRaw.length,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
agencyId: instance.agency_id,
|
||||
skipped: false,
|
||||
subscriptionsUpserted: subscriptionsForUpsert.length,
|
||||
domainsUpserted: domainsForUpsert.length,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message.slice(0, 1000) : "Sync failed";
|
||||
|
||||
await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
status: "error",
|
||||
error_message: message,
|
||||
last_sync_at: startedAtIso,
|
||||
last_sync_status: "failed",
|
||||
last_sync_error: message,
|
||||
})
|
||||
.eq("id", instance.id)
|
||||
.eq("agency_id", instance.agency_id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
19
lib/stripe.ts
Normal file
19
lib/stripe.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
let stripeClient: Stripe | null = null;
|
||||
|
||||
export function getStripeClient() {
|
||||
if (!env.stripeSecretKey) {
|
||||
throw new Error("STRIPE_SECRET_KEY is not configured");
|
||||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
stripeClient = new Stripe(env.stripeSecretKey, {
|
||||
apiVersion: "2025-02-24.acacia",
|
||||
});
|
||||
}
|
||||
|
||||
return stripeClient;
|
||||
}
|
||||
13
lib/supabase/admin.ts
Normal file
13
lib/supabase/admin.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
export function createSupabaseAdminClient() {
|
||||
return createClient<Database>(env.supabaseUrl, env.supabaseServiceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
9
lib/supabase/browser.ts
Normal file
9
lib/supabase/browser.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
|
||||
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
export function createSupabaseBrowserClient() {
|
||||
return createClientComponentClient<Database>();
|
||||
}
|
||||
8
lib/supabase/route.ts
Normal file
8
lib/supabase/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
export function createSupabaseRouteClient() {
|
||||
return createRouteHandlerClient<Database>({ cookies });
|
||||
}
|
||||
8
lib/supabase/server.ts
Normal file
8
lib/supabase/server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createServerComponentClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
import type { Database } from "@/lib/types";
|
||||
|
||||
export function createSupabaseServerClient() {
|
||||
return createServerComponentClient<Database>({ cookies });
|
||||
}
|
||||
311
lib/types.ts
Normal file
311
lib/types.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json }
|
||||
| Json[];
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
agencies: {
|
||||
Row: { id: string; name: string; created_at: string };
|
||||
Insert: { id?: string; name: string; created_at?: string };
|
||||
Update: { id?: string; name?: string; created_at?: string };
|
||||
};
|
||||
agency_members: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
user_id: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
user_id: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
user_id?: string;
|
||||
role?: "owner" | "admin" | "member";
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
clients: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
notes?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
name?: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
notes?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
plesk_instances: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
auth_type: "api_key" | "basic";
|
||||
encrypted_secret: string;
|
||||
secret_kid: string | null;
|
||||
status: "connected" | "error" | "disabled" | "syncing";
|
||||
error_message: string | null;
|
||||
last_connected_at: string | null;
|
||||
last_sync_at: string | null;
|
||||
last_sync_status: string | null;
|
||||
last_sync_error: string | null;
|
||||
last_sync_subscriptions: number;
|
||||
last_sync_domains: number;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
name: string;
|
||||
base_url: string;
|
||||
auth_type: "api_key" | "basic";
|
||||
encrypted_secret: string;
|
||||
secret_kid?: string | null;
|
||||
status?: "connected" | "error" | "disabled" | "syncing";
|
||||
error_message?: string | null;
|
||||
last_connected_at?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_status?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
last_sync_subscriptions?: number;
|
||||
last_sync_domains?: number;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
auth_type?: "api_key" | "basic";
|
||||
encrypted_secret?: string;
|
||||
secret_kid?: string | null;
|
||||
status?: "connected" | "error" | "disabled" | "syncing";
|
||||
error_message?: string | null;
|
||||
last_connected_at?: string | null;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_status?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
last_sync_subscriptions?: number;
|
||||
last_sync_domains?: number;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
plesk_subscriptions: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
plesk_instance_id: string;
|
||||
client_id: string | null;
|
||||
plesk_subscription_id: string;
|
||||
name: string | null;
|
||||
status: string | null;
|
||||
owner_login: string | null;
|
||||
plan_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
plesk_instance_id: string;
|
||||
client_id?: string | null;
|
||||
plesk_subscription_id: string;
|
||||
name?: string | null;
|
||||
status?: string | null;
|
||||
owner_login?: string | null;
|
||||
plan_name?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
plesk_instance_id?: string;
|
||||
client_id?: string | null;
|
||||
plesk_subscription_id?: string;
|
||||
name?: string | null;
|
||||
status?: string | null;
|
||||
owner_login?: string | null;
|
||||
plan_name?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
plesk_domains: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
plesk_instance_id: string;
|
||||
subscription_id: string | null;
|
||||
external_subscription_id: string | null;
|
||||
plesk_domain_id: string;
|
||||
domain_name: string;
|
||||
status: string | null;
|
||||
hosting_type: string | null;
|
||||
source_created_at: string | null;
|
||||
aliases_count: number | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
plesk_instance_id: string;
|
||||
subscription_id?: string | null;
|
||||
external_subscription_id?: string | null;
|
||||
plesk_domain_id: string;
|
||||
domain_name: string;
|
||||
status?: string | null;
|
||||
hosting_type?: string | null;
|
||||
source_created_at?: string | null;
|
||||
aliases_count?: number | null;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
plesk_instance_id?: string;
|
||||
subscription_id?: string | null;
|
||||
external_subscription_id?: string | null;
|
||||
plesk_domain_id?: string;
|
||||
domain_name?: string;
|
||||
status?: string | null;
|
||||
hosting_type?: string | null;
|
||||
source_created_at?: string | null;
|
||||
aliases_count?: number | null;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
actions_log: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
actor_user_id: string | null;
|
||||
target_type: string;
|
||||
target_id: string | null;
|
||||
action: string;
|
||||
status: "pending" | "success" | "failed";
|
||||
error_message: string | null;
|
||||
metadata: Json;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
actor_user_id?: string | null;
|
||||
target_type: string;
|
||||
target_id?: string | null;
|
||||
action: string;
|
||||
status?: "pending" | "success" | "failed";
|
||||
error_message?: string | null;
|
||||
metadata?: Json;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
actor_user_id?: string | null;
|
||||
target_type?: string;
|
||||
target_id?: string | null;
|
||||
action?: string;
|
||||
status?: "pending" | "success" | "failed";
|
||||
error_message?: string | null;
|
||||
metadata?: Json;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
billing_accounts: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
stripe_customer_id: string | null;
|
||||
stripe_subscription_id: string | null;
|
||||
plan_id: string | null;
|
||||
subscription_status: string | null;
|
||||
current_period_end: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
stripe_customer_id?: string | null;
|
||||
stripe_subscription_id?: string | null;
|
||||
plan_id?: string | null;
|
||||
subscription_status?: string | null;
|
||||
current_period_end?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
stripe_customer_id?: string | null;
|
||||
stripe_subscription_id?: string | null;
|
||||
plan_id?: string | null;
|
||||
subscription_status?: string | null;
|
||||
current_period_end?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
entitlements: {
|
||||
Row: {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
key: string;
|
||||
value: Json;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
agency_id: string;
|
||||
key: string;
|
||||
value?: Json;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
agency_id?: string;
|
||||
key?: string;
|
||||
value?: Json;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user