Compare commits

..

6 Commits

Author SHA1 Message Date
Owen Schwartz
aed86ce4ba Merge pull request #2663 from fosrl/dev
change route name
2026-03-16 20:03:56 -07:00
miloschwartz
2c2be50b19 change route name 2026-03-16 20:02:57 -07:00
Owen Schwartz
e2db4c6246 Merge pull request #2662 from fosrl/batch-add-client-to-resources
batch add client to resources
2026-03-16 19:53:47 -07:00
miloschwartz
c4839fee08 Merge branch 'dev' into batch-add-client-to-resources 2026-03-16 17:58:37 -07:00
miloschwartz
965b7026f0 add batch endpoint 2026-03-16 17:58:20 -07:00
Owen
e14e15fcbb Revert: Also update lastPing for legacy 2026-03-16 17:47:06 -07:00
6 changed files with 263 additions and 9 deletions

View File

@@ -1,11 +1,11 @@
module installer
go 1.25.0
go 1.24.0
require (
github.com/charmbracelet/huh v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.41.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

View File

@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -97,7 +97,6 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
accumulator = new Map<string, AccumulatorEntry>();
const currentTime = new Date().toISOString();
const currentTimeEpochSeconds = Math.floor(new Date().getTime() / 1000);
// Sort by publicKey for consistent lock ordering across concurrent
// writers — deadlock-prevention strategy.
@@ -121,7 +120,6 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
lastBandwidthUpdate: currentTime,
lastPing: currentTimeEpochSeconds
})
.where(eq(sites.pubKey, publicKey))
.returning({

View File

@@ -309,6 +309,14 @@ authenticated.post(
siteResource.removeClientFromSiteResource
);
authenticated.post(
"/client/:clientId/site-resources",
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
logActionAudit(ActionsEnum.setResourceUsers),
siteResource.batchAddClientToSiteResources
);
authenticated.put(
"/org/:orgId/resource",
verifyApiKeyOrgAccess,

View File

@@ -0,0 +1,247 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
clients,
clientSiteResources,
siteResources,
apiKeyOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import {
rebuildClientAssociationsFromClient,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
const batchAddClientToSiteResourcesParamsSchema = z
.object({
clientId: z.string().transform(Number).pipe(z.number().int().positive())
})
.strict();
const batchAddClientToSiteResourcesBodySchema = z
.object({
siteResourceIds: z
.array(z.number().int().positive())
.min(1, "At least one siteResourceId is required")
})
.strict();
registry.registerPath({
method: "post",
path: "/client/{clientId}/site-resources",
description: "Add a machine client to multiple site resources at once.",
tags: [OpenAPITags.Client],
request: {
params: batchAddClientToSiteResourcesParamsSchema,
body: {
content: {
"application/json": {
schema: batchAddClientToSiteResourcesBodySchema
}
}
}
},
responses: {}
});
export async function batchAddClientToSiteResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const apiKey = req.apiKey;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
const parsedParams =
batchAddClientToSiteResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { clientId } = parsedParams.data;
const { siteResourceIds } = parsedBody.data;
const uniqueSiteResourceIds = [...new Set(siteResourceIds)];
const batchSiteResources = await db
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, uniqueSiteResourceIds)
);
if (batchSiteResources.length !== uniqueSiteResourceIds.length) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"One or more site resources not found"
)
);
}
if (!apiKey.isRoot) {
const orgIds = [
...new Set(batchSiteResources.map((sr) => sr.orgId))
];
if (orgIds.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"All site resources must belong to the same organization"
)
);
}
const orgId = orgIds[0];
const [apiKeyOrgRow] = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
if (!apiKeyOrgRow) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to the organization of the specified site resources"
)
);
}
const [clientInOrg] = await db
.select()
.from(clients)
.where(
and(
eq(clients.clientId, clientId),
eq(clients.orgId, orgId)
)
)
.limit(1);
if (!clientInOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to the specified client"
)
);
}
}
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Client not found")
);
}
if (client.userId !== null) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"This endpoint only supports machine (non-user) clients; the specified client is associated with a user"
)
);
}
const existingEntries = await db
.select({
siteResourceId: clientSiteResources.siteResourceId
})
.from(clientSiteResources)
.where(
and(
eq(clientSiteResources.clientId, clientId),
inArray(
clientSiteResources.siteResourceId,
batchSiteResources.map((sr) => sr.siteResourceId)
)
)
);
const existingSiteResourceIds = new Set(
existingEntries.map((e) => e.siteResourceId)
);
const siteResourcesToAdd = batchSiteResources.filter(
(sr) => !existingSiteResourceIds.has(sr.siteResourceId)
);
if (siteResourcesToAdd.length === 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Client is already assigned to all specified site resources"
)
);
}
await db.transaction(async (trx) => {
for (const siteResource of siteResourcesToAdd) {
await trx.insert(clientSiteResources).values({
clientId,
siteResourceId: siteResource.siteResourceId
});
}
await rebuildClientAssociationsFromClient(client, trx);
});
return response(res, {
data: {
addedCount: siteResourcesToAdd.length,
skippedCount:
batchSiteResources.length - siteResourcesToAdd.length,
siteResourceIds: siteResourcesToAdd.map(
(sr) => sr.siteResourceId
)
},
success: true,
error: false,
message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`,
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -15,4 +15,5 @@ export * from "./addUserToSiteResource";
export * from "./removeUserFromSiteResource";
export * from "./setSiteResourceClients";
export * from "./addClientToSiteResource";
export * from "./batchAddClientToSiteResources";
export * from "./removeClientFromSiteResource";