mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-17 18:36:37 +00:00
Merge pull request #2371 from Fredkiss3/refactor/paginated-tables
feat: server side filtered, ordered & paginated tables
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
varchar,
|
||||
boolean,
|
||||
integer,
|
||||
bigint,
|
||||
real,
|
||||
text,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { alias } from "yargs";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const domains = pgTable("domains", {
|
||||
domainId: varchar("domainId").primaryKey(),
|
||||
@@ -188,7 +186,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||
hcMethod: varchar("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ export const siteResources = pgTable("siteResources", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: varchar("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { no } from "zod/v4/locales";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
@@ -214,7 +207,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
}).default(true),
|
||||
hcMethod: text("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
@@ -246,7 +241,7 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: text("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
|
||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { approvals, db, type Approval } from "@server/db";
|
||||
import { eq, sql, and } from "drizzle-orm";
|
||||
import { eq, sql, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -88,7 +88,7 @@ export async function countApprovals(
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`
|
||||
inArray(approvals.decision, state)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
currentFingerprint,
|
||||
type Approval
|
||||
} from "@server/db";
|
||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
||||
import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
|
||||
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
limit: z
|
||||
.string()
|
||||
limit: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20),
|
||||
cursorPending: z.coerce // pending cursor
|
||||
.number<string>()
|
||||
.int()
|
||||
.max(1) // 0 means non pending
|
||||
.min(0) // 1 means pending
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
.catch(undefined),
|
||||
cursorTimestamp: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
approvalState: z
|
||||
.enum(["pending", "approved", "denied", "all"])
|
||||
.optional()
|
||||
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
|
||||
.pipe(z.number().int().positive().optional())
|
||||
});
|
||||
|
||||
async function queryApprovals(
|
||||
orgId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
approvalState: z.infer<typeof querySchema>["approvalState"],
|
||||
clientId?: number
|
||||
) {
|
||||
async function queryApprovals({
|
||||
orgId,
|
||||
limit,
|
||||
approvalState,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
clientId
|
||||
}: {
|
||||
orgId: string;
|
||||
limit: number;
|
||||
approvalState: z.infer<typeof querySchema>["approvalState"];
|
||||
cursorPending?: number;
|
||||
cursorTimestamp?: number;
|
||||
clientId?: number;
|
||||
}) {
|
||||
let state: Array<Approval["decision"]> = [];
|
||||
switch (approvalState) {
|
||||
case "pending":
|
||||
@@ -83,6 +99,26 @@ async function queryApprovals(
|
||||
state = ["approved", "denied", "pending"];
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`
|
||||
];
|
||||
|
||||
if (clientId) {
|
||||
conditions.push(eq(approvals.clientId, clientId));
|
||||
}
|
||||
|
||||
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
|
||||
|
||||
if (cursorPending != null && cursorTimestamp != null) {
|
||||
// https://stackoverflow.com/a/79720298/10322846
|
||||
// composite cursor, next data means (pending, timestamp) <= cursor
|
||||
conditions.push(
|
||||
lte(pendingSortKey, cursorPending),
|
||||
lte(approvals.timestamp, cursorTimestamp)
|
||||
);
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.select({
|
||||
approvalId: approvals.approvalId,
|
||||
@@ -105,7 +141,8 @@ async function queryApprovals(
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
fingerprintHostname: currentFingerprint.hostname,
|
||||
timestamp: approvals.timestamp
|
||||
})
|
||||
.from(approvals)
|
||||
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
||||
@@ -118,22 +155,12 @@ async function queryApprovals(
|
||||
)
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||
.where(
|
||||
and(
|
||||
eq(approvals.orgId, orgId),
|
||||
sql`${approvals.decision} in ${state}`,
|
||||
...(clientId ? [eq(approvals.clientId, clientId)] : [])
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
|
||||
desc(approvals.timestamp)
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pendingSortKey), desc(approvals.timestamp))
|
||||
.limit(limit + 1); // the `+1` is used for the cursor
|
||||
|
||||
// Process results to format device names and build fingerprint objects
|
||||
return res.map((approval) => {
|
||||
const approvalsList = res.slice(0, limit).map((approval) => {
|
||||
const model = approval.deviceModel || null;
|
||||
const deviceName = approval.clientName
|
||||
? getUserDeviceName(model, approval.clientName)
|
||||
@@ -152,15 +179,15 @@ async function queryApprovals(
|
||||
|
||||
const fingerprint = hasFingerprintData
|
||||
? {
|
||||
platform: approval.fingerprintPlatform || null,
|
||||
osVersion: approval.fingerprintOsVersion || null,
|
||||
kernelVersion: approval.fingerprintKernelVersion || null,
|
||||
arch: approval.fingerprintArch || null,
|
||||
deviceModel: approval.deviceModel || null,
|
||||
serialNumber: approval.fingerprintSerialNumber || null,
|
||||
username: approval.fingerprintUsername || null,
|
||||
hostname: approval.fingerprintHostname || null
|
||||
}
|
||||
platform: approval.fingerprintPlatform ?? null,
|
||||
osVersion: approval.fingerprintOsVersion ?? null,
|
||||
kernelVersion: approval.fingerprintKernelVersion ?? null,
|
||||
arch: approval.fingerprintArch ?? null,
|
||||
deviceModel: approval.deviceModel ?? null,
|
||||
serialNumber: approval.fingerprintSerialNumber ?? null,
|
||||
username: approval.fingerprintUsername ?? null,
|
||||
hostname: approval.fingerprintHostname ?? null
|
||||
}
|
||||
: null;
|
||||
|
||||
const {
|
||||
@@ -183,11 +210,30 @@ async function queryApprovals(
|
||||
niceId: approval.niceId || null
|
||||
};
|
||||
});
|
||||
let nextCursorPending: number | null = null;
|
||||
let nextCursorTimestamp: number | null = null;
|
||||
if (res.length > limit) {
|
||||
const lastItem = res[limit];
|
||||
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
|
||||
nextCursorTimestamp = lastItem.timestamp;
|
||||
}
|
||||
return {
|
||||
approvalsList,
|
||||
nextCursorPending,
|
||||
nextCursorTimestamp
|
||||
};
|
||||
}
|
||||
|
||||
export type ListApprovalsResponse = {
|
||||
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
approvals: NonNullable<
|
||||
Awaited<ReturnType<typeof queryApprovals>>
|
||||
>["approvalsList"];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
cursorPending: number | null;
|
||||
cursorTimestamp: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function listApprovals(
|
||||
@@ -215,17 +261,25 @@ export async function listApprovals(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, approvalState, clientId } = parsedQuery.data;
|
||||
const {
|
||||
limit,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
approvalState,
|
||||
clientId
|
||||
} = parsedQuery.data;
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const approvalsList = await queryApprovals(
|
||||
orgId.toString(),
|
||||
limit,
|
||||
offset,
|
||||
approvalState,
|
||||
clientId
|
||||
);
|
||||
const { approvalsList, nextCursorPending, nextCursorTimestamp } =
|
||||
await queryApprovals({
|
||||
orgId: orgId.toString(),
|
||||
limit,
|
||||
cursorPending,
|
||||
cursorTimestamp,
|
||||
approvalState,
|
||||
clientId
|
||||
});
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -237,7 +291,8 @@ export async function listApprovals(
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
cursorPending: nextCursorPending,
|
||||
cursorTimestamp: nextCursorTimestamp
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
|
||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
|
||||
|
||||
const licenseData = req.body;
|
||||
|
||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
||||
if (
|
||||
licenseData.tier != "big_license" &&
|
||||
licenseData.tier != "small_license"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
|
||||
return next(
|
||||
createHttpError(
|
||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||
apiResponse.message || "Failed to create license from Fossorial API"
|
||||
apiResponse.message ||
|
||||
"Failed to create license from Fossorial API"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -112,7 +117,10 @@ export async function generateNewEnterpriseLicense(
|
||||
);
|
||||
}
|
||||
|
||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
||||
const tier =
|
||||
licenseData.tier === "big_license"
|
||||
? LicenseId.BIG_LICENSE
|
||||
: LicenseId.SMALL_LICENSE;
|
||||
const tierPrice = getLicensePriceSet()[tier];
|
||||
|
||||
const session = await stripe!.checkout.sessions.create({
|
||||
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
|
||||
{
|
||||
price: tierPrice, // Use the standard tier
|
||||
quantity: 1
|
||||
},
|
||||
}
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
|
||||
export * from "./blockClient";
|
||||
export * from "./unblockClient";
|
||||
export * from "./listClients";
|
||||
export * from "./listUserDevices";
|
||||
export * from "./updateClient";
|
||||
export * from "./getClient";
|
||||
export * from "./createUserClient";
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { db, olms, users } from "@server/db";
|
||||
import {
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint,
|
||||
db,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
sites,
|
||||
userClients,
|
||||
clientSitesAssociationsCache,
|
||||
currentFingerprint
|
||||
users
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||
|
||||
@@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const listClientsSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
filter: z.enum(["user", "machine"]).optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
status: z.preprocess(
|
||||
(val: string | undefined) => {
|
||||
if (val) {
|
||||
return val.split(","); // the search query array is an array joined by commas
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z
|
||||
.array(z.enum(["active", "blocked", "archived"]))
|
||||
.optional()
|
||||
.default(["active"])
|
||||
.catch(["active"])
|
||||
.openapi({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["active", "blocked", "archived"]
|
||||
},
|
||||
default: ["active"],
|
||||
description:
|
||||
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
function queryClients(
|
||||
orgId: string,
|
||||
accessibleClientIds: number[],
|
||||
filter?: "user" | "machine"
|
||||
) {
|
||||
const conditions = [
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId)
|
||||
];
|
||||
|
||||
// Add filter condition based on filter type
|
||||
if (filter === "user") {
|
||||
conditions.push(isNotNull(clients.userId));
|
||||
} else if (filter === "machine") {
|
||||
conditions.push(isNull(clients.userId));
|
||||
}
|
||||
|
||||
function queryClientsBase() {
|
||||
return db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
@@ -142,22 +194,13 @@ function queryClients(
|
||||
approvalState: clients.approvalState,
|
||||
olmArchived: olms.archived,
|
||||
archived: clients.archived,
|
||||
blocked: clients.blocked,
|
||||
deviceModel: currentFingerprint.deviceModel,
|
||||
fingerprintPlatform: currentFingerprint.platform,
|
||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
blocked: clients.blocked
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||
.where(and(...conditions));
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||
}
|
||||
|
||||
async function getSiteAssociations(clientIds: number[]) {
|
||||
@@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||
}
|
||||
|
||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
||||
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||
sites: Array<{
|
||||
siteId: number;
|
||||
siteName: string | null;
|
||||
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
||||
|
||||
type OlmWithUpdateAvailable = ClientWithSites;
|
||||
|
||||
export type ListClientsResponse = {
|
||||
export type ListClientsResponse = PaginatedResponse<{
|
||||
clients: Array<ClientWithSites>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -218,7 +260,8 @@ export async function listClients(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset, filter } = parsedQuery.data;
|
||||
const { page, pageSize, online, query, status, sort_by, order } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -267,28 +310,73 @@ export async function listClients(
|
||||
const accessibleClientIds = accessibleClients.map(
|
||||
(client) => client.clientId
|
||||
);
|
||||
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
|
||||
|
||||
// Get client count with filter
|
||||
const countConditions = [
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId)
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId),
|
||||
isNull(clients.userId)
|
||||
)
|
||||
];
|
||||
|
||||
if (filter === "user") {
|
||||
countConditions.push(isNotNull(clients.userId));
|
||||
} else if (filter === "machine") {
|
||||
countConditions.push(isNull(clients.userId));
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(clients.online, online));
|
||||
}
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(clients)
|
||||
.where(and(...countConditions));
|
||||
if (status.length > 0) {
|
||||
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||
|
||||
const clientsList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0].count;
|
||||
if (status.includes("active")) {
|
||||
filterAggregates.push(
|
||||
and(eq(clients.archived, false), eq(clients.blocked, false))
|
||||
);
|
||||
}
|
||||
|
||||
if (status.includes("archived")) {
|
||||
filterAggregates.push(eq(clients.archived, true));
|
||||
}
|
||||
if (status.includes("blocked")) {
|
||||
filterAggregates.push(eq(clients.blocked, true));
|
||||
}
|
||||
|
||||
conditions.push(or(...filterAggregates));
|
||||
}
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||
|
||||
const listMachinesQuery = baseQuery
|
||||
.limit(page)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(clients[sort_by])
|
||||
: desc(clients[sort_by])
|
||||
: asc(clients.clientId)
|
||||
);
|
||||
|
||||
const [clientsList, totalCount] = await Promise.all([
|
||||
listMachinesQuery,
|
||||
countQuery
|
||||
]);
|
||||
|
||||
// Get associated sites for all clients
|
||||
const clientIds = clientsList.map((client) => client.clientId);
|
||||
@@ -319,14 +407,8 @@ export async function listClients(
|
||||
|
||||
// Merge clients with their site associations and replace name with device name
|
||||
const clientsWithSites = clientsList.map((client) => {
|
||||
const model = client.deviceModel || null;
|
||||
let newName = client.name;
|
||||
if (filter === "user") {
|
||||
newName = getUserDeviceName(model, client.name);
|
||||
}
|
||||
return {
|
||||
...client,
|
||||
name: newName,
|
||||
sites: sitesByClient[client.clientId] || []
|
||||
};
|
||||
});
|
||||
@@ -371,8 +453,8 @@ export async function listClients(
|
||||
clients: olmsWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
500
server/routers/client/listUserDevices.ts
Normal file
500
server/routers/client/listUserDevices.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
clients,
|
||||
currentFingerprint,
|
||||
db,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
userClients,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { getUserDeviceName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import NodeCache from "node-cache";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||
|
||||
async function getLatestOlmVersion(): Promise<string | null> {
|
||||
try {
|
||||
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
|
||||
if (cachedVersion) {
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1500);
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/fosrl/olm/tags",
|
||||
{
|
||||
signal: controller.signal
|
||||
}
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let tags = await response.json();
|
||||
if (!Array.isArray(tags) || tags.length === 0) {
|
||||
logger.warn("No tags found for Olm repository");
|
||||
return null;
|
||||
}
|
||||
tags = tags.filter((version) => !version.name.includes("rc"));
|
||||
const latestVersion = tags[0].name;
|
||||
|
||||
olmVersionCache.set("latestOlmVersion", latestVersion);
|
||||
|
||||
return latestVersion;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
|
||||
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
|
||||
logger.warn("Connection timeout while fetching latest Olm version");
|
||||
} else {
|
||||
logger.warn(
|
||||
"Error fetching latest Olm version:",
|
||||
error.message || error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const listUserDevicesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserDevicesSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
}),
|
||||
agent: z
|
||||
.enum([
|
||||
"windows",
|
||||
"android",
|
||||
"cli",
|
||||
"olm",
|
||||
"macos",
|
||||
"ios",
|
||||
"ipados",
|
||||
"unknown"
|
||||
])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: [
|
||||
"windows",
|
||||
"android",
|
||||
"cli",
|
||||
"olm",
|
||||
"macos",
|
||||
"ios",
|
||||
"ipados",
|
||||
"unknown"
|
||||
],
|
||||
description:
|
||||
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
|
||||
}),
|
||||
status: z.preprocess(
|
||||
(val: string | undefined) => {
|
||||
if (val) {
|
||||
return val.split(","); // the search query array is an array joined by commas
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z
|
||||
.array(
|
||||
z.enum(["active", "pending", "denied", "blocked", "archived"])
|
||||
)
|
||||
.optional()
|
||||
.default(["active", "pending"])
|
||||
.catch(["active", "pending"])
|
||||
.openapi({
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: ["active", "pending", "denied", "blocked", "archived"]
|
||||
},
|
||||
default: ["active", "pending"],
|
||||
description:
|
||||
"Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
function queryUserDevicesBase() {
|
||||
return db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
orgId: clients.orgId,
|
||||
name: clients.name,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet,
|
||||
megabytesIn: clients.megabytesIn,
|
||||
megabytesOut: clients.megabytesOut,
|
||||
orgName: orgs.name,
|
||||
type: clients.type,
|
||||
online: clients.online,
|
||||
olmVersion: olms.version,
|
||||
userId: clients.userId,
|
||||
username: users.username,
|
||||
userEmail: users.email,
|
||||
niceId: clients.niceId,
|
||||
agent: olms.agent,
|
||||
approvalState: clients.approvalState,
|
||||
olmArchived: olms.archived,
|
||||
archived: clients.archived,
|
||||
blocked: clients.blocked,
|
||||
deviceModel: currentFingerprint.deviceModel,
|
||||
fingerprintPlatform: currentFingerprint.platform,
|
||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
||||
fingerprintArch: currentFingerprint.arch,
|
||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||
fingerprintUsername: currentFingerprint.username,
|
||||
fingerprintHostname: currentFingerprint.hostname
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.leftJoin(users, eq(clients.userId, users.userId))
|
||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||
}
|
||||
|
||||
type OlmWithUpdateAvailable = Awaited<
|
||||
ReturnType<typeof queryUserDevicesBase>
|
||||
>[0] & {
|
||||
olmUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type ListUserDevicesResponse = PaginatedResponse<{
|
||||
devices: Array<OlmWithUpdateAvailable>;
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/user-devices",
|
||||
description: "List all user devices for an organization.",
|
||||
tags: [OpenAPITags.Client, OpenAPITags.Org],
|
||||
request: {
|
||||
query: listUserDevicesSchema,
|
||||
params: listUserDevicesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listUserDevices(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize, query, sort_by, online, status, agent, order } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleClients;
|
||||
if (req.user) {
|
||||
accessibleClients = await db
|
||||
.select({
|
||||
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||
})
|
||||
.from(userClients)
|
||||
.fullJoin(
|
||||
roleClients,
|
||||
eq(userClients.clientId, roleClients.clientId)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userClients.userId, req.user!.userId),
|
||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleClients = await db
|
||||
.select({ clientId: clients.clientId })
|
||||
.from(clients)
|
||||
.where(eq(clients.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleClientIds = accessibleClients.map(
|
||||
(client) => client.clientId
|
||||
);
|
||||
// Get client count with filter
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(clients.clientId, accessibleClientIds),
|
||||
eq(clients.orgId, orgId),
|
||||
isNotNull(clients.userId)
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${clients.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${clients.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${users.email})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(clients.online, online));
|
||||
}
|
||||
|
||||
const agentValueMap = {
|
||||
windows: "Pangolin Windows",
|
||||
android: "Pangolin Android",
|
||||
ios: "Pangolin iOS",
|
||||
ipados: "Pangolin iPadOS",
|
||||
macos: "Pangolin macOS",
|
||||
cli: "Pangolin CLI",
|
||||
olm: "Olm CLI"
|
||||
} satisfies Record<
|
||||
Exclude<typeof agent, undefined | "unknown">,
|
||||
string
|
||||
>;
|
||||
if (typeof agent !== "undefined") {
|
||||
if (agent === "unknown") {
|
||||
conditions.push(isNull(olms.agent));
|
||||
} else {
|
||||
conditions.push(eq(olms.agent, agentValueMap[agent]));
|
||||
}
|
||||
}
|
||||
|
||||
if (status.length > 0) {
|
||||
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||
|
||||
if (status.includes("active")) {
|
||||
filterAggregates.push(
|
||||
and(
|
||||
eq(clients.archived, false),
|
||||
eq(clients.blocked, false),
|
||||
build !== "oss"
|
||||
? or(
|
||||
eq(clients.approvalState, "approved"),
|
||||
isNull(clients.approvalState) // approval state of `NULL` means approved by default
|
||||
)
|
||||
: undefined // undefined are automatically ignored by `drizzle-orm`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (status.includes("archived")) {
|
||||
filterAggregates.push(eq(clients.archived, true));
|
||||
}
|
||||
if (status.includes("blocked")) {
|
||||
filterAggregates.push(eq(clients.blocked, true));
|
||||
}
|
||||
|
||||
if (build !== "oss") {
|
||||
if (status.includes("pending")) {
|
||||
filterAggregates.push(eq(clients.approvalState, "pending"));
|
||||
}
|
||||
if (status.includes("denied")) {
|
||||
filterAggregates.push(eq(clients.approvalState, "denied"));
|
||||
}
|
||||
}
|
||||
|
||||
conditions.push(or(...filterAggregates));
|
||||
}
|
||||
|
||||
const baseQuery = queryUserDevicesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||
|
||||
const listDevicesQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(clients[sort_by])
|
||||
: desc(clients[sort_by])
|
||||
: asc(clients.clientId)
|
||||
);
|
||||
|
||||
const [clientsList, totalCount] = await Promise.all([
|
||||
listDevicesQuery,
|
||||
countQuery
|
||||
]);
|
||||
|
||||
// Merge clients with their site associations and replace name with device name
|
||||
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
|
||||
(client) => {
|
||||
const model = client.deviceModel || null;
|
||||
const newName = getUserDeviceName(model, client.name);
|
||||
const OlmWithUpdate: OlmWithUpdateAvailable = {
|
||||
...client,
|
||||
name: newName
|
||||
};
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
OlmWithUpdate.olmUpdateAvailable = false;
|
||||
return OlmWithUpdate;
|
||||
}
|
||||
);
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
const latestOlmVersion = await getLatestOlmVersion();
|
||||
|
||||
if (latestOlmVersion) {
|
||||
olmsWithUpdates.forEach((client) => {
|
||||
try {
|
||||
client.olmUpdateAvailable = semver.lt(
|
||||
client.olmVersion ? client.olmVersion : "",
|
||||
latestOlmVersion
|
||||
);
|
||||
} catch (error) {
|
||||
client.olmUpdateAvailable = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't let it block the response
|
||||
logger.warn(
|
||||
"Failed to check for OLM updates, continuing without update info:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return response<ListUserDevicesResponse>(res, {
|
||||
data: {
|
||||
devices: olmsWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Clients retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,13 @@ authenticated.get(
|
||||
client.listClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-devices",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listClients),
|
||||
client.listUserDevices
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId",
|
||||
verifyClientAccess,
|
||||
|
||||
@@ -866,6 +866,13 @@ authenticated.get(
|
||||
client.listClients
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-devices",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listClients),
|
||||
client.listUserDevices
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/client/:clientId",
|
||||
verifyApiKeyClientAccess,
|
||||
|
||||
@@ -1,74 +1,99 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
} from "@server/db";
|
||||
import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resources,
|
||||
roleResources,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
targetHealthCheck
|
||||
userResources
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
like,
|
||||
not,
|
||||
or,
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listResourcesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listResourcesSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
enabled: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter resources based on enabled status"
|
||||
}),
|
||||
authState: z
|
||||
.enum(["protected", "not_protected", "none"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["protected", "not_protected", "none"],
|
||||
description:
|
||||
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||
}),
|
||||
healthStatus: z
|
||||
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
||||
description:
|
||||
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
||||
})
|
||||
});
|
||||
|
||||
// (resource fields + a single joined target)
|
||||
type JoinedRow = {
|
||||
resourceId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
passwordId: number | null;
|
||||
sso: boolean;
|
||||
pincodeId: number | null;
|
||||
whitelist: boolean;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId: string | null;
|
||||
headerAuthId: number | null;
|
||||
|
||||
targetId: number | null;
|
||||
targetIp: string | null;
|
||||
targetPort: number | null;
|
||||
targetEnabled: boolean | null;
|
||||
|
||||
hcHealth: string | null;
|
||||
hcEnabled: boolean | null;
|
||||
};
|
||||
|
||||
// grouped by resource with targets[])
|
||||
export type ResourceWithTargets = {
|
||||
resourceId: number;
|
||||
@@ -91,11 +116,32 @@ export type ResourceWithTargets = {
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
// Aggregate filters
|
||||
const total_targets = count(targets.targetId);
|
||||
const healthy_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
const unknown_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
const unhealthy_targets = sql<number>`SUM(
|
||||
CASE
|
||||
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) `;
|
||||
|
||||
function queryResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
@@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
niceId: resources.niceId,
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
targetId: targets.targetId,
|
||||
targetIp: targets.ip,
|
||||
targetPort: targets.port,
|
||||
targetEnabled: targets.enabled,
|
||||
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
.groupBy(
|
||||
resources.resourceId,
|
||||
resourcePassword.passwordId,
|
||||
resourcePincode.pincodeId,
|
||||
resourceHeaderAuth.headerAuthId,
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
);
|
||||
}
|
||||
|
||||
export type ListResourcesResponse = {
|
||||
export type ListResourcesResponse = PaginatedResponse<{
|
||||
resources: ResourceWithTargets[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -190,7 +229,8 @@ export async function listResources(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { page, pageSize, authState, enabled, query, healthStatus } =
|
||||
parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -252,14 +292,133 @@ export async function listResources(
|
||||
(resource) => resource.resourceId
|
||||
);
|
||||
|
||||
const countQuery: any = db
|
||||
.select({ count: count() })
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(resources.resourceId, accessibleResourceIds),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
];
|
||||
|
||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resources.fullDomain})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(resources.enabled, enabled));
|
||||
}
|
||||
|
||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
||||
if (typeof authState !== "undefined") {
|
||||
switch (authState) {
|
||||
case "none":
|
||||
conditions.push(eq(resources.http, false));
|
||||
break;
|
||||
case "protected":
|
||||
conditions.push(
|
||||
or(
|
||||
eq(resources.sso, true),
|
||||
eq(resources.emailWhitelistEnabled, true),
|
||||
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePincode.pincodeId)),
|
||||
not(isNull(resourcePassword.passwordId))
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not_protected":
|
||||
conditions.push(
|
||||
not(eq(resources.sso, true)),
|
||||
not(eq(resources.emailWhitelistEnabled, true)),
|
||||
isNull(resourceHeaderAuth.headerAuthId),
|
||||
isNull(resourcePincode.pincodeId),
|
||||
isNull(resourcePassword.passwordId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
|
||||
|
||||
if (typeof healthStatus !== "undefined") {
|
||||
switch (healthStatus) {
|
||||
case "healthy":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${healthy_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
case "degraded":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${unhealthy_targets} > 0`
|
||||
);
|
||||
break;
|
||||
case "no_targets":
|
||||
aggregateFilters = sql`${total_targets} = 0`;
|
||||
break;
|
||||
case "offline":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${healthy_targets} = 0`,
|
||||
sql`${unhealthy_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
case "unknown":
|
||||
aggregateFilters = and(
|
||||
sql`${total_targets} > 0`,
|
||||
sql`${unknown_targets} = ${total_targets}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcesBase()
|
||||
.where(and(...conditions))
|
||||
.having(aggregateFilters);
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(baseQuery.as("filtered_resources"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(resources.resourceId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const resourceIdList = rows.map((row) => row.resourceId);
|
||||
const allResourceTargets =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
resourceId: targets.resourceId,
|
||||
ip: targets.ip,
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
healthStatus: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
})
|
||||
.from(targets)
|
||||
.where(inArray(targets.resourceId, resourceIdList))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
@@ -288,44 +447,20 @@ export async function listResources(
|
||||
map.set(row.resourceId, entry);
|
||||
}
|
||||
|
||||
if (
|
||||
row.targetId != null &&
|
||||
row.targetIp &&
|
||||
row.targetPort != null &&
|
||||
row.targetEnabled != null
|
||||
) {
|
||||
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
||||
"unknown";
|
||||
|
||||
if (row.hcEnabled && row.hcHealth) {
|
||||
healthStatus = row.hcHealth as
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
| "unknown";
|
||||
}
|
||||
|
||||
entry.targets.push({
|
||||
targetId: row.targetId,
|
||||
ip: row.targetIp,
|
||||
port: row.targetPort,
|
||||
enabled: row.targetEnabled,
|
||||
healthStatus: healthStatus
|
||||
});
|
||||
}
|
||||
entry.targets = allResourceTargets.filter(
|
||||
(t) => t.resourceId === entry.resourceId
|
||||
);
|
||||
}
|
||||
|
||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
return response<ListResourcesResponse>(res, {
|
||||
data: {
|
||||
resources: resourcesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { db, exitNodes, newts } from "@server/db";
|
||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
db,
|
||||
exitNodes,
|
||||
newts,
|
||||
orgs,
|
||||
remoteExitNodes,
|
||||
roleSites,
|
||||
sites,
|
||||
userSites
|
||||
} from "@server/db";
|
||||
import cache from "@server/lib/cache";
|
||||
import response from "@server/lib/response";
|
||||
import { and, count, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import semver from "semver";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import semver from "semver";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
async function getLatestNewtVersion(): Promise<string | null> {
|
||||
try {
|
||||
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
const listSitesSchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["megabytesIn", "megabytesOut"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["megabytesIn", "megabytesOut"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
online: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter by online status"
|
||||
})
|
||||
});
|
||||
|
||||
function querySites(orgId: string, accessibleSiteIds: number[]) {
|
||||
function querySitesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
@@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
||||
.leftJoin(
|
||||
remoteExitNodes,
|
||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||
newtUpdateAvailable?: boolean;
|
||||
};
|
||||
|
||||
export type ListSitesResponse = {
|
||||
export type ListSitesResponse = PaginatedResponse<{
|
||||
sites: SiteWithUpdateAvailable[];
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
}>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -160,7 +203,6 @@ export async function listSites(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
@@ -203,34 +245,67 @@ export async function listSites(
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
||||
const { pageSize, page, query, sort_by, order, online } =
|
||||
parsedQuery.data;
|
||||
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
}
|
||||
|
||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
||||
const totalCountResult = await countQuery;
|
||||
const totalCount = totalCountResult[0].count;
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase().where(and(...conditions))
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
? asc(sites[sort_by])
|
||||
: desc(sites[sort_by])
|
||||
: asc(sites.siteId)
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
siteListQuery
|
||||
]);
|
||||
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
|
||||
(site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
}
|
||||
);
|
||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||
// Initially set to false, will be updated if version check succeeds
|
||||
siteWithUpdate.newtUpdateAvailable = false;
|
||||
return siteWithUpdate;
|
||||
});
|
||||
|
||||
// Try to get the latest version, but don't block if it fails
|
||||
try {
|
||||
@@ -267,8 +342,8 @@ export async function listSites(
|
||||
sites: sitesWithUpdates,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function createSiteResource(
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
mode,
|
||||
mode: mode as "host" | "cidr",
|
||||
// protocol: mode === "port" ? protocol : null,
|
||||
// proxyPort: mode === "port" ? proxyPort : null,
|
||||
// destinationPort: mode === "port" ? destinationPort : null,
|
||||
|
||||
@@ -1,41 +1,90 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
mode: z
|
||||
.enum(["host", "cidr"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["host", "cidr"],
|
||||
description: "Filter site resources by mode"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = {
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteResources: (SiteResource & {
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
siteAddress: string | null;
|
||||
})[];
|
||||
};
|
||||
}>;
|
||||
|
||||
function querySiteResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
@@ -80,39 +129,67 @@ export async function listAllSiteResourcesByOrg(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { page, pageSize, query, mode } = parsedQuery.data;
|
||||
|
||||
// Get all site resources for the org with site names
|
||||
const siteResourcesList = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
||||
.where(eq(siteResources.orgId, orgId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${siteResources.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.destination})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.alias})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${siteResources.aliasAddress})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${sites.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
if (mode) {
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
querySiteResourcesBase().where(and(...conditions))
|
||||
);
|
||||
|
||||
const [siteResourcesList, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(siteResources.siteResourceId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site resources retrieved successfully",
|
||||
|
||||
@@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
.set({
|
||||
hcHealth: healthStatus.status
|
||||
hcHealth: healthStatus.status as
|
||||
| "unknown"
|
||||
| "healthy"
|
||||
| "unhealthy"
|
||||
})
|
||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||
.execute();
|
||||
|
||||
5
server/types/Pagination.ts
Normal file
5
server/types/Pagination.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Pagination = { total: number; pageSize: number; page: number };
|
||||
|
||||
export type PaginatedResponse<T> = T & {
|
||||
pagination: Pagination;
|
||||
};
|
||||
Reference in New Issue
Block a user