add reject endpoint

This commit is contained in:
miloschwartz
2026-07-12 17:48:48 -04:00
parent 933ca71c16
commit a8f3f71021
11 changed files with 309 additions and 41 deletions
+1
View File
@@ -21,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
updateSiteApprovals = "updateSiteApprovals",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
+3 -5
View File
@@ -14,8 +14,6 @@ import {
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
@@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects(
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
logger.debug(
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
);
continue;
}
if (site.pubKey && site.type === "newt") {
+82 -3
View File
@@ -1,6 +1,7 @@
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import {
db,
resources,
siteNetworks,
siteResources,
targets,
@@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function getPendingResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
if (resourceIds.length === 0) {
return [];
}
const rows = await trx
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.status, "pending")
)
);
return rows.map((row) => row.resourceId);
}
export async function getPendingSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
if (siteResourceIds.length === 0) {
return [];
}
const rows = await trx
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.status, "pending")
)
);
return rows.map((row) => row.siteResourceId);
}
export async function getPendingAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [resourceIds, siteResourceIds] = await Promise.all([
getPendingResourceIdsForSite(siteId, trx),
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
]);
return resourceIds.length + siteResourceIds.length;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
@@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite(
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
const [deletedResources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
return { resources: deletedResources, siteResources: siteResourcesDeleted };
}
export async function deletePendingAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
const siteResourceIds = await getPendingSiteResourceIdsForSite(
siteId,
orgId,
trx
);
const [deletedResources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources: deletedResources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
+10 -2
View File
@@ -251,11 +251,19 @@ authenticated.post(
authenticated.post(
"/site/:siteId/approve",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSite),
logActionAudit(ActionsEnum.updateSite),
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
logActionAudit(ActionsEnum.updateSiteApprovals),
site.approveSite
);
authenticated.post(
"/site/:siteId/reject",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
logActionAudit(ActionsEnum.updateSiteApprovals),
site.rejectSite
);
authenticated.delete(
"/site/:siteId",
verifySiteAccess,
-7
View File
@@ -139,13 +139,6 @@ authenticated.post(
site.updateSite
);
authenticated.post(
"/site/:siteId/approve",
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.updateSite),
logActionAudit(ActionsEnum.updateSite),
site.approveSite
);
authenticated.post(
"/org/:orgId/reset-bandwidth",
verifyApiKeyOrgAccess,
+9 -9
View File
@@ -159,15 +159,21 @@ export async function deleteSite(
siteResources: []
};
await db.transaction(async (trx) => {
if (deleteResources) {
if (deleteResources) {
await db.transaction(async (trx) => {
resourceSideEffects = await deleteAssociatedResourcesForSite(
siteId,
site.orgId,
trx
);
}
});
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
await db.transaction(async (trx) => {
if (site.type == "wireguard") {
if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey);
@@ -180,12 +186,6 @@ export async function deleteSite(
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
});
if (deleteResources) {
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
if (deletedNewt) {
const payload = {
type: `newt/wg/terminate`,
+1
View File
@@ -4,6 +4,7 @@ export * from "./createSite";
export * from "./deleteSite";
export * from "./updateSite";
export * from "./approveSite";
export * from "./rejectSite";
export * from "./listSites";
export * from "./listSiteRoles";
export * from "./pickSiteDefaults";
+178
View File
@@ -0,0 +1,178 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
import {
deletePendingAssociatedResourcesForSite,
exceedsSiteAssociatedResourceDeleteLimit,
getPendingAssociatedResourceCountForSite,
runDeleteSiteAssociatedResourcesSideEffects,
MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
type DeleteSiteAssociatedResourcesSideEffects
} from "@server/lib/deleteSiteAssociatedResources";
const rejectSiteParamsSchema = z.strictObject({
siteId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site/{siteId}/reject",
description:
"Reject a pending site by deleting it and any associated resources that are still pending.",
tags: [OpenAPITags.Site],
request: {
params: rejectSiteParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function rejectSite(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = rejectSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteId } = parsedParams.data;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
if (!site.orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Site with ID ${siteId} has no organization`
)
);
}
const pendingAssociatedResourceCount =
await getPendingAssociatedResourceCountForSite(siteId, site.orgId);
if (
exceedsSiteAssociatedResourceDeleteLimit(
pendingAssociatedResourceCount
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources`
)
);
}
const [deletedNewt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
resources: [],
siteResources: []
};
await db.transaction(async (trx) => {
resourceSideEffects = await deletePendingAssociatedResourcesForSite(
siteId,
site.orgId,
trx
);
});
await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects);
await db.transaction(async (trx) => {
if (site.type == "wireguard") {
if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") {
await cleanupSiteAssociations(site, trx);
}
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
});
if (deletedNewt) {
const payload = {
type: `newt/wg/terminate`,
data: {}
};
sendToClient(deletedNewt.newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Site rejected successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
if (createHttpError.isHttpError(error)) {
return next(error);
}
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}