mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 06:39:52 +02:00
Merge branch 'resource-launcher-compact' into dev
This commit is contained in:
@@ -327,6 +327,14 @@ authenticated.get(
|
||||
siteResource.listAllSiteResourcesByOrg
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/site-resource/:siteResourceId",
|
||||
verifyOrgAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||
siteResource.getSiteResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/site-resource/:siteResourceId",
|
||||
verifySiteResourceAccess,
|
||||
@@ -470,6 +478,12 @@ authenticated.get(
|
||||
launcher.listLauncherGroups
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/scale",
|
||||
verifyOrgAccess,
|
||||
launcher.listLauncherScale
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/launcher/resources",
|
||||
verifyOrgAccess,
|
||||
@@ -494,6 +508,12 @@ authenticated.get(
|
||||
launcher.listLauncherViews
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/invalidate-cache",
|
||||
verifyOrgAccess,
|
||||
launcher.invalidateLauncherCache
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/launcher/views",
|
||||
verifyOrgAccess,
|
||||
@@ -506,6 +526,18 @@ authenticated.put(
|
||||
launcher.updateLauncherView
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/launcher/default-view",
|
||||
verifyOrgAccess,
|
||||
launcher.upsertLauncherDefaultView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/default-view",
|
||||
verifyOrgAccess,
|
||||
launcher.deleteLauncherDefaultView
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/launcher/views/:viewId",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -79,7 +79,8 @@ export async function createLauncherView(
|
||||
),
|
||||
createdAt: created.createdAt,
|
||||
updatedAt: created.updatedAt,
|
||||
isOrgWide: created.userId == null
|
||||
isOrgWide: created.userId == null,
|
||||
isDefault: created.isDefault
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import {
|
||||
deleteAllDefaultViewOverrides,
|
||||
deleteDefaultViewOverride
|
||||
} from "./launcherDefaultView";
|
||||
|
||||
const deleteLauncherDefaultViewBodySchema = z.strictObject({
|
||||
orgWide: z.boolean().optional().default(false),
|
||||
all: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export async function deleteLauncherDefaultView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.all) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await deleteAllDefaultViewOverrides(orgId, userId);
|
||||
} else if (parsed.data.orgWide) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: true
|
||||
});
|
||||
} else {
|
||||
await deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: false
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher default view reset successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error resetting launcher default view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,15 @@ export async function deleteLauncherView(
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isDefault) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"The default view cannot be deleted from here"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./types";
|
||||
export { listLauncherGroups } from "./listLauncherGroups";
|
||||
export { listLauncherScale } from "./listLauncherScale";
|
||||
export { listLauncherResources } from "./listLauncherResources";
|
||||
export { listLauncherSites } from "./listLauncherSites";
|
||||
export { listLauncherLabels } from "./listLauncherLabels";
|
||||
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
|
||||
export { createLauncherView } from "./createLauncherView";
|
||||
export { updateLauncherView } from "./updateLauncherView";
|
||||
export { deleteLauncherView } from "./deleteLauncherView";
|
||||
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
|
||||
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
|
||||
export { invalidateLauncherCache } from "./invalidateLauncherCache";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
|
||||
async function invalidateLauncherCacheForUser(
|
||||
orgId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const prefixes = [
|
||||
`launcherAccessibleIds:${orgId}:${userId}:`,
|
||||
`launcher:groups:${orgId}:${userId}:`,
|
||||
`launcher:results:${orgId}:${userId}:`,
|
||||
`launcher:scale:counts:${orgId}:${userId}:`
|
||||
];
|
||||
|
||||
const keys = (
|
||||
await Promise.all(
|
||||
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
|
||||
)
|
||||
).flat();
|
||||
|
||||
if (keys.length > 0) {
|
||||
await cache.del(keys);
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateLauncherCache(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
await invalidateLauncherCacheForUser(orgId, userId);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher cache invalidated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error invalidating launcher cache:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { db, launcherViews } from "@server/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import moment from "moment";
|
||||
import {
|
||||
launcherViewConfigSchema,
|
||||
type LauncherDefaultViewOverrides,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "./types";
|
||||
|
||||
export function mapViewRow(
|
||||
row: typeof launcherViews.$inferSelect
|
||||
): LauncherViewRecord {
|
||||
return {
|
||||
viewId: row.viewId,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isOrgWide: row.userId == null,
|
||||
isDefault: row.isDefault
|
||||
};
|
||||
}
|
||||
|
||||
export function extractDefaultViewOverrides(
|
||||
rows: Array<typeof launcherViews.$inferSelect>
|
||||
): LauncherDefaultViewOverrides {
|
||||
const overrideRows = rows.filter((row) => row.isDefault);
|
||||
|
||||
const personalRow = overrideRows.find((row) => row.userId !== null);
|
||||
const orgWideRow = overrideRows.find((row) => row.userId === null);
|
||||
|
||||
return {
|
||||
personal: personalRow ? mapViewRow(personalRow) : null,
|
||||
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function listVisibleLauncherViews(
|
||||
rows: Array<typeof launcherViews.$inferSelect>
|
||||
): LauncherViewRecord[] {
|
||||
return rows.filter((row) => !row.isDefault).map(mapViewRow);
|
||||
}
|
||||
|
||||
export async function findDefaultViewOverride(
|
||||
orgId: string,
|
||||
orgWide: boolean,
|
||||
userId: string
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(launcherViews)
|
||||
.where(
|
||||
and(
|
||||
eq(launcherViews.orgId, orgId),
|
||||
eq(launcherViews.isDefault, true),
|
||||
orgWide
|
||||
? isNull(launcherViews.userId)
|
||||
: eq(launcherViews.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return existing ?? null;
|
||||
}
|
||||
|
||||
export async function upsertDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide,
|
||||
config
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
orgWide: boolean;
|
||||
config: LauncherViewConfig;
|
||||
}) {
|
||||
const now = moment().toISOString();
|
||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(launcherViews)
|
||||
.set({
|
||||
config: JSON.stringify(config),
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(launcherViews.viewId, existing.viewId))
|
||||
.returning();
|
||||
|
||||
return mapViewRow(updated);
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(launcherViews)
|
||||
.values({
|
||||
orgId,
|
||||
userId: orgWide ? null : userId,
|
||||
name: "",
|
||||
isDefault: true,
|
||||
config: JSON.stringify(config),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapViewRow(created);
|
||||
}
|
||||
|
||||
export async function deleteDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide
|
||||
}: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
orgWide: boolean;
|
||||
}) {
|
||||
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(launcherViews)
|
||||
.where(eq(launcherViews.viewId, existing.viewId));
|
||||
}
|
||||
|
||||
export async function deleteAllDefaultViewOverrides(
|
||||
orgId: string,
|
||||
userId: string
|
||||
) {
|
||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
|
||||
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
exitNodes,
|
||||
@@ -31,13 +32,15 @@ import {
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql
|
||||
sql,
|
||||
type SQL
|
||||
} from "drizzle-orm";
|
||||
import {
|
||||
formatPublicResourceAccess,
|
||||
formatSiteResourceAccess
|
||||
} from "./formatLauncherAccess";
|
||||
import {
|
||||
LAUNCHER_FLAT_GROUP_KEY,
|
||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||
type LauncherFilterListQuery,
|
||||
@@ -59,6 +62,68 @@ export type AccessibleIds = {
|
||||
};
|
||||
|
||||
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
||||
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
|
||||
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
|
||||
|
||||
type LauncherResourcesCacheEntry = {
|
||||
items: LauncherResource[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
type LauncherGroupsCacheEntry = {
|
||||
groups: LauncherGroup[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
function launcherListQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery,
|
||||
extra?: Record<string, string>
|
||||
) {
|
||||
const payload = JSON.stringify({
|
||||
roles: [...userRoleIds].sort((a, b) => a - b),
|
||||
query: query.query,
|
||||
groupBy: query.groupBy,
|
||||
siteIds: query.siteIds ?? "",
|
||||
labelIds: query.labelIds ?? "",
|
||||
sort_by: query.sort_by,
|
||||
order: query.order,
|
||||
...extra
|
||||
});
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function launcherResourcesQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query, {
|
||||
groupKey: query.groupKey
|
||||
});
|
||||
}
|
||||
|
||||
function launcherGroupsQueryHash(
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
) {
|
||||
return launcherListQueryHash(userRoleIds, query);
|
||||
}
|
||||
|
||||
function launcherResourcesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:results:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherGroupsCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
queryHash: string
|
||||
) {
|
||||
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
|
||||
}
|
||||
|
||||
function launcherAccessibleIdsCacheKey(
|
||||
orgId: string,
|
||||
@@ -194,6 +259,21 @@ function searchPattern(query: string) {
|
||||
return `%${query.trim()}%`;
|
||||
}
|
||||
|
||||
function combineOrConditions(
|
||||
...conditions: (SQL | undefined)[]
|
||||
): SQL | undefined {
|
||||
const parts = conditions.filter(
|
||||
(condition): condition is SQL => !!condition
|
||||
);
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
return or(...parts);
|
||||
}
|
||||
|
||||
function buildSearchConditionForPublic(
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
@@ -205,7 +285,17 @@ function buildSearchConditionForPublic(
|
||||
const queryList = [
|
||||
like(sql`LOWER(${resources.name})`, pattern),
|
||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
|
||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
db
|
||||
.select({ id: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
||||
)
|
||||
];
|
||||
|
||||
if (labelsFeatureEnabled) {
|
||||
@@ -238,6 +328,11 @@ function buildSearchConditionForSiteResource(
|
||||
const queryList = [
|
||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||
like(sql`LOWER(${siteResources.destination})`, pattern),
|
||||
like(
|
||||
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
|
||||
pattern
|
||||
),
|
||||
like(sql`LOWER(${siteResources.scheme})`, pattern),
|
||||
like(sql`LOWER(${siteResources.alias})`, pattern),
|
||||
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
||||
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
||||
@@ -262,6 +357,79 @@ function buildSearchConditionForSiteResource(
|
||||
return or(...queryList);
|
||||
}
|
||||
|
||||
async function filterPublicResourceIdsByTextSearch(
|
||||
orgId: string,
|
||||
resourceIds: number[],
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
): Promise<number[]> {
|
||||
if (!query.trim() || resourceIds.length === 0) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForPublic(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return resourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
async function filterSiteResourceIdsByTextSearch(
|
||||
orgId: string,
|
||||
siteResourceIds: number[],
|
||||
query: string,
|
||||
labelsFeatureEnabled: boolean
|
||||
): Promise<number[]> {
|
||||
if (!query.trim() || siteResourceIds.length === 0) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const textMatch = combineOrConditions(
|
||||
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
|
||||
buildSiteNameSearchCondition(query)
|
||||
);
|
||||
if (!textMatch) {
|
||||
return siteResourceIds;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.leftJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.siteResourceId);
|
||||
}
|
||||
|
||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
||||
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
||||
}
|
||||
@@ -588,9 +756,8 @@ async function listSiteGroups(
|
||||
});
|
||||
|
||||
const total = groups.length;
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups: groups.slice(offset, offset + query.pageSize),
|
||||
groups,
|
||||
total
|
||||
};
|
||||
}
|
||||
@@ -788,26 +955,53 @@ async function listLabelGroups(
|
||||
});
|
||||
|
||||
const total = groups.length;
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups: groups.slice(offset, offset + query.pageSize),
|
||||
groups,
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
async function listLauncherGroupsForUserUncached(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
): Promise<LauncherGroupsCacheEntry> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
if (query.groupBy === "label") {
|
||||
return listLabelGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
return listSiteGroups(orgId, accessible, query);
|
||||
}
|
||||
|
||||
export async function listLauncherGroupsForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery
|
||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
|
||||
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
|
||||
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
|
||||
|
||||
if (query.groupBy === "label") {
|
||||
return listLabelGroups(orgId, accessible, query);
|
||||
let result = cached;
|
||||
if (!result) {
|
||||
result = await listLauncherGroupsForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
|
||||
}
|
||||
|
||||
return listSiteGroups(orgId, accessible, query);
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
groups: result.groups.slice(offset, offset + query.pageSize),
|
||||
total: result.total
|
||||
};
|
||||
}
|
||||
|
||||
async function mapPublicResources(
|
||||
@@ -1018,26 +1212,6 @@ function filterResourcesByLabel(
|
||||
);
|
||||
}
|
||||
|
||||
function filterResourcesBySearch(
|
||||
items: LauncherResource[],
|
||||
query: string
|
||||
): LauncherResource[] {
|
||||
if (!query.trim()) {
|
||||
return items;
|
||||
}
|
||||
const pattern = query.trim().toLowerCase();
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(pattern) ||
|
||||
item.accessDisplay.toLowerCase().includes(pattern) ||
|
||||
item.accessCopyValue.toLowerCase().includes(pattern) ||
|
||||
item.labels.some((label) =>
|
||||
label.name.toLowerCase().includes(pattern)
|
||||
) ||
|
||||
item.site?.name.toLowerCase().includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
function sortLauncherResources(
|
||||
items: LauncherResource[],
|
||||
order: "asc" | "desc"
|
||||
@@ -1050,12 +1224,12 @@ function sortLauncherResources(
|
||||
});
|
||||
}
|
||||
|
||||
export async function listLauncherResourcesForUser(
|
||||
async function listLauncherResourcesForUserUncached(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||
): Promise<LauncherResourcesCacheEntry> {
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
|
||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||
@@ -1128,6 +1302,27 @@ export async function listLauncherResourcesForUser(
|
||||
}
|
||||
}
|
||||
|
||||
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
||||
|
||||
if (query.query.trim()) {
|
||||
if (filteredResourceIds.length > 0) {
|
||||
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
||||
orgId,
|
||||
filteredResourceIds,
|
||||
query.query,
|
||||
labelsFeatureEnabled
|
||||
);
|
||||
}
|
||||
if (filteredSiteResourceIds.length > 0) {
|
||||
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
||||
orgId,
|
||||
filteredSiteResourceIds,
|
||||
query.query,
|
||||
labelsFeatureEnabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const labelMaps = await fetchLabelsForResources(
|
||||
orgId,
|
||||
filteredResourceIds,
|
||||
@@ -1159,21 +1354,48 @@ export async function listLauncherResourcesForUser(
|
||||
]);
|
||||
|
||||
let items = [...publicItems, ...siteItems];
|
||||
items = filterResourcesBySearch(items, query.query);
|
||||
|
||||
if (query.groupBy === "label") {
|
||||
items = filterResourcesByLabel(items, query.groupKey);
|
||||
} else if (query.groupBy === "site") {
|
||||
items = filterResourcesBySite(items, query.groupKey);
|
||||
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
||||
if (query.groupBy === "label") {
|
||||
items = filterResourcesByLabel(items, query.groupKey);
|
||||
} else if (query.groupBy === "site") {
|
||||
items = filterResourcesBySite(items, query.groupKey);
|
||||
}
|
||||
}
|
||||
|
||||
items = sortLauncherResources(items, query.order);
|
||||
|
||||
const total = items.length;
|
||||
return {
|
||||
items,
|
||||
total: items.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLauncherResourcesForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherListQuery & { groupKey: string }
|
||||
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
|
||||
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
|
||||
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
|
||||
|
||||
let result = cached;
|
||||
if (!result) {
|
||||
result = await listLauncherResourcesForUserUncached(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
query
|
||||
);
|
||||
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
|
||||
}
|
||||
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
return {
|
||||
resources: items.slice(offset, offset + query.pageSize),
|
||||
total
|
||||
resources: result.items.slice(offset, offset + query.pageSize),
|
||||
total: result.total
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||
import {
|
||||
listLauncherGroupsForUser,
|
||||
resolveAccessibleIds
|
||||
} from "./launcherResourceAccess";
|
||||
import {
|
||||
parseIdListParam,
|
||||
type LauncherScaleInfo,
|
||||
type LauncherScaleQuery
|
||||
} from "./types";
|
||||
|
||||
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
|
||||
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
|
||||
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
|
||||
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
|
||||
|
||||
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
|
||||
|
||||
type LauncherScaleCountsCacheEntry = {
|
||||
resourceCount: number;
|
||||
siteGroupCount: number;
|
||||
labelGroupCount: number;
|
||||
mode: LauncherScaleInfo["mode"];
|
||||
};
|
||||
|
||||
function launcherScaleCountsCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
roleIds: number[]
|
||||
) {
|
||||
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
|
||||
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
|
||||
}
|
||||
|
||||
function buildScaleCapabilities(
|
||||
counts: LauncherScaleCountsCacheEntry,
|
||||
query: LauncherScaleQuery
|
||||
): LauncherScaleInfo["capabilities"] {
|
||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||
const labelFilterIds = parseIdListParam(query.labelIds);
|
||||
|
||||
return {
|
||||
allowSiteGrouping:
|
||||
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
|
||||
(siteFilterIds.length > 0 &&
|
||||
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||
allowLabelGrouping:
|
||||
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
|
||||
(labelFilterIds.length > 0 &&
|
||||
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
|
||||
requireSearchOrFilter:
|
||||
counts.mode === "compact" &&
|
||||
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
|
||||
};
|
||||
}
|
||||
|
||||
async function getLauncherScaleCountsForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[]
|
||||
): Promise<LauncherScaleCountsCacheEntry> {
|
||||
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
|
||||
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||
const resourceCount =
|
||||
accessible.resourceIds.length + accessible.siteResourceIds.length;
|
||||
|
||||
const baselineQuery = {
|
||||
query: "",
|
||||
groupBy: "site" as const,
|
||||
siteIds: undefined,
|
||||
labelIds: undefined,
|
||||
sort_by: "name" as const,
|
||||
order: "asc" as const,
|
||||
page: 1,
|
||||
pageSize: 1
|
||||
};
|
||||
|
||||
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
|
||||
await Promise.all([
|
||||
listLauncherGroupsForUser(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds,
|
||||
baselineQuery
|
||||
),
|
||||
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
|
||||
...baselineQuery,
|
||||
groupBy: "label"
|
||||
})
|
||||
]);
|
||||
|
||||
const mode =
|
||||
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
|
||||
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
|
||||
? "full"
|
||||
: "compact";
|
||||
|
||||
const result: LauncherScaleCountsCacheEntry = {
|
||||
resourceCount,
|
||||
siteGroupCount,
|
||||
labelGroupCount,
|
||||
mode
|
||||
};
|
||||
|
||||
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLauncherScaleForUser(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
userRoleIds: number[],
|
||||
query: LauncherScaleQuery
|
||||
): Promise<LauncherScaleInfo> {
|
||||
const counts = await getLauncherScaleCountsForUser(
|
||||
orgId,
|
||||
userId,
|
||||
userRoleIds
|
||||
);
|
||||
|
||||
return {
|
||||
mode: counts.mode,
|
||||
resourceCount: counts.resourceCount,
|
||||
siteGroupCount: counts.siteGroupCount,
|
||||
labelGroupCount: counts.labelGroupCount,
|
||||
capabilities: buildScaleCapabilities(counts, query)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { getLauncherScaleForUser } from "./launcherScale";
|
||||
import { launcherScaleQuerySchema } from "./types";
|
||||
|
||||
export async function listLauncherScale(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = launcherScaleQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const scale = await getLauncherScaleForUser(
|
||||
orgId,
|
||||
userId,
|
||||
req.userOrgRoleIds ?? [],
|
||||
parsed.data
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: { scale },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher scale retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error listing launcher scale:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
||||
|
||||
function mapViewRow(
|
||||
row: typeof launcherViews.$inferSelect
|
||||
): LauncherViewRecord {
|
||||
return {
|
||||
viewId: row.viewId,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
name: row.name,
|
||||
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isOrgWide: row.userId == null
|
||||
};
|
||||
}
|
||||
import {
|
||||
extractDefaultViewOverrides,
|
||||
listVisibleLauncherViews
|
||||
} from "./launcherDefaultView";
|
||||
|
||||
export async function listLauncherViews(
|
||||
req: Request,
|
||||
@@ -51,7 +39,8 @@ export async function listLauncherViews(
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
views: rows.map(mapViewRow)
|
||||
views: listVisibleLauncherViews(rows),
|
||||
defaultViewOverrides: extractDefaultViewOverrides(rows)
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { z } from "zod";
|
||||
|
||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
||||
|
||||
export const launcherViewConfigSchema = z.object({
|
||||
groupBy: z.enum(["site", "label"]).default("site"),
|
||||
groupBy: z.enum(["site", "label", "none"]).default("site"),
|
||||
layout: z.enum(["grid", "list"]).default("grid"),
|
||||
sortBy: z.literal("name").default("name"),
|
||||
order: z.enum(["asc", "desc"]).default("asc"),
|
||||
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isOrgWide: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type LauncherDefaultViewOverrides = {
|
||||
personal: LauncherViewRecord | null;
|
||||
orgWide: LauncherViewRecord | null;
|
||||
};
|
||||
|
||||
export function getEffectiveDefaultLauncherConfig(
|
||||
overrides: LauncherDefaultViewOverrides
|
||||
): LauncherViewConfig {
|
||||
if (overrides.personal) {
|
||||
return overrides.personal.config;
|
||||
}
|
||||
|
||||
if (overrides.orgWide) {
|
||||
return overrides.orgWide.config;
|
||||
}
|
||||
|
||||
return defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
export type ListLauncherViewsResponse = {
|
||||
views: LauncherViewRecord[];
|
||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
||||
};
|
||||
|
||||
export const launcherFilterListQuerySchema = z.strictObject({
|
||||
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(500)
|
||||
.default(500),
|
||||
.catch(20)
|
||||
.default(20),
|
||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||
query: z.string().optional().default("")
|
||||
});
|
||||
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
|
||||
.default(20),
|
||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||
query: z.string().optional().default(""),
|
||||
groupBy: z.enum(["site", "label"]).optional().default("site"),
|
||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||
groupKey: z.string().optional(),
|
||||
siteIds: z.string().optional(),
|
||||
labelIds: z.string().optional(),
|
||||
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||
export type LauncherViewSelection =
|
||||
| { type: "default" }
|
||||
| { type: "saved"; viewId: number };
|
||||
|
||||
export type LauncherScaleCapabilities = {
|
||||
allowSiteGrouping: boolean;
|
||||
allowLabelGrouping: boolean;
|
||||
requireSearchOrFilter: boolean;
|
||||
};
|
||||
|
||||
export type LauncherScaleInfo = {
|
||||
mode: "full" | "compact";
|
||||
resourceCount: number;
|
||||
siteGroupCount: number;
|
||||
labelGroupCount: number;
|
||||
capabilities: LauncherScaleCapabilities;
|
||||
};
|
||||
|
||||
export type ListLauncherScaleResponse = {
|
||||
scale: LauncherScaleInfo;
|
||||
};
|
||||
|
||||
export const launcherScaleQuerySchema = z.strictObject({
|
||||
query: z.string().optional().default(""),
|
||||
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
|
||||
siteIds: z.string().optional(),
|
||||
labelIds: z.string().optional(),
|
||||
sort_by: z.literal("name").optional().default("name"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("asc")
|
||||
});
|
||||
|
||||
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
|
||||
|
||||
@@ -66,6 +66,15 @@ export async function updateLauncherView(
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isDefault) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Use the default view save endpoint for this view"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const isPersonalView = existing.userId === userId;
|
||||
const isOrgWideView = existing.userId == null;
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
@@ -135,7 +144,8 @@ export async function updateLauncherView(
|
||||
),
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
isOrgWide: updated.userId == null
|
||||
isOrgWide: updated.userId == null,
|
||||
isDefault: updated.isDefault
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { z } from "zod";
|
||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||
import { upsertDefaultViewOverride } from "./launcherDefaultView";
|
||||
import { launcherViewConfigSchema } from "./types";
|
||||
|
||||
const upsertLauncherDefaultViewBodySchema = z.strictObject({
|
||||
config: launcherViewConfigSchema,
|
||||
orgWide: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export async function upsertLauncherDefaultView(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const orgId = req.userOrgId;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsed.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.data.orgWide) {
|
||||
const canManageOrgWide = await checkUserActionPermission(
|
||||
ActionsEnum.createOrgWideLauncherView,
|
||||
req
|
||||
);
|
||||
if (!canManageOrgWide) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const view = await upsertDefaultViewOverride({
|
||||
orgId,
|
||||
userId,
|
||||
orgWide: parsed.data.orgWide,
|
||||
config: parsed.data.config
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: view,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Launcher default view saved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
console.error("Error saving launcher default view:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user