diff --git a/messages/en-US.json b/messages/en-US.json index 4701d4da1..64526b405 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -864,8 +864,8 @@ "policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthSummary": "Header configured", - "policyAuthHeaderName": "Header name", - "policyAuthHeaderValue": "Expected value", + "policyAuthHeaderName": "Username", + "policyAuthHeaderValue": "Password", "policyAuthSetPasscode": "Set Passcode", "policyAuthSetPincode": "Set PIN Code", "policyAuthSetEmailWhitelist": "Set Email Whitelist", @@ -3577,18 +3577,23 @@ "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "resourceLauncherTitle": "Resource Launcher", - "resourceLauncherDescription": "View resource details and launch them from one place", - "resourceLauncherSearchPlaceholder": "Search all sites...", + "resourceLauncherDescription": "View all available resources and launch them from one central hub", + "resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveDefaultPersonal": "Save for Me", "resourceLauncherResetView": "Reset View", + "resourceLauncherResetSystemDefault": "Reset to System Default", + "resourceLauncherSystemDefaultRestored": "System default restored", + "resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", "resourceLauncherMakePersonal": "Make Personal", "resourceLauncherFilter": "Filter", + "resourceLauncherFilterWithCount": "Filter, {count} applied", "resourceLauncherSort": "Sort", "resourceLauncherSortAscending": "Sort ascending", "resourceLauncherSortDescending": "Sort descending", @@ -3596,6 +3601,7 @@ "resourceLauncherGroupBy": "Group By", "resourceLauncherGroupBySite": "Site", "resourceLauncherGroupByLabel": "Label", + "resourceLauncherGroupByNone": "None", "resourceLauncherLayout": "Layout", "resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutList": "List", @@ -3603,8 +3609,20 @@ "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", + "resourceLauncherDeleteViewTitle": "Delete View", + "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", + "resourceLauncherDeleteViewConfirm": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", - "resourceLauncherResourceDetailsDescription": "View details for this resource.", + "resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.", + "resourceLauncherResourceDetails": "Resource Details", + "resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.", + "resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.", + "resourceLauncherPrivateClientRequiredTitle": "Client Connection Required", + "resourceLauncherDownloadClient": "Download client", + "resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.", + "resourceLauncherNoPortRestrictions": "No port restrictions", + "resourceLauncherTcp": "TCP", + "resourceLauncherUdp": "UDP", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", @@ -3613,6 +3631,12 @@ "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", + "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", + "resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", + "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", + "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", + "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/db/pg/poolConfig.ts b/server/db/pg/poolConfig.ts index b893c2159..af6c10520 100644 --- a/server/db/pg/poolConfig.ts +++ b/server/db/pg/poolConfig.ts @@ -18,7 +18,17 @@ export function createPoolConfig( keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle // Allow connections to be released and recreated more aggressively // to avoid stale connections building up - allowExitOnIdle: false + allowExitOnIdle: false, + // Disable JIT compilation for this connection. Our hot-path queries + // (e.g. resource-by-domain lookups) join many tables but only ever + // return a handful of rows. When planner row estimates drift (e.g. + // due to autovacuum lag under write-heavy load), Postgres decides + // these plans are expensive enough to JIT-compile, which can add + // multiple seconds of pure compilation overhead per query and + // saturate the connection pool. JIT never pays off for these + // short-lived OLTP queries, so it's disabled outright rather than + // relying on statistics staying fresh. + options: "-c jit=off" }; } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 683c572b6..5375e4fc2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", { }), name: varchar("name").notNull(), config: text("config").notNull(), + isDefault: boolean("isDefault").notNull().default(false), createdAt: varchar("createdAt").notNull(), updatedAt: varchar("updatedAt").notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 28797bcd9..5f0c2f9a7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", { }), name: text("name").notNull(), config: text("config").notNull(), + isDefault: integer("isDefault", { mode: "boolean" }) + .notNull() + .default(false), createdAt: text("createdAt").notNull(), updatedAt: text("updatedAt").notNull() }); diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts index 6b91b3462..59e7103d8 100644 --- a/server/lib/blueprints/applyNewtDockerBlueprint.ts +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint( source: "NEWT" }); } catch (error) { - logger.error(`Failed to update database from config: ${error}`); + logger.debug(`Failed to update database from config: ${error}`); await sendToClient(newtId, { type: "newt/blueprint/results", data: { diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 92d5d9cfe..a2dce7818 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -65,6 +65,7 @@ export function verifyValidSubscription(tiers: Tier[]) { return next(); } catch (e) { + console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, diff --git a/server/routers/blueprints/applyJSONBlueprint.ts b/server/routers/blueprints/applyJSONBlueprint.ts index 8ad41e9e4..ae231f8c8 100644 --- a/server/routers/blueprints/applyJSONBlueprint.ts +++ b/server/routers/blueprints/applyJSONBlueprint.ts @@ -99,7 +99,7 @@ export async function applyJSONBlueprint( source: "API" }); } catch (error) { - logger.error(`Failed to update database from config: ${error}`); + logger.debug(`Failed to update database from config: ${error}`); return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/external.ts b/server/routers/external.ts index 5d770c064..8a22393ca 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -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, diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 593bad304..195dfc555 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -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, diff --git a/server/routers/launcher/deleteLauncherDefaultView.ts b/server/routers/launcher/deleteLauncherDefaultView.ts new file mode 100644 index 000000000..50a1a7beb --- /dev/null +++ b/server/routers/launcher/deleteLauncherDefaultView.ts @@ -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 { + 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" + ) + ); + } +} diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index beaf1b433..8377872fb 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -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( diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index 939bf74bc..1c3fed44c 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -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"; diff --git a/server/routers/launcher/invalidateLauncherCache.ts b/server/routers/launcher/invalidateLauncherCache.ts new file mode 100644 index 000000000..5e4ff1618 --- /dev/null +++ b/server/routers/launcher/invalidateLauncherCache.ts @@ -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 { + 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 { + 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" + ) + ); + } +} diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts new file mode 100644 index 000000000..a0c2fd066 --- /dev/null +++ b/server/routers/launcher/launcherDefaultView.ts @@ -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 +): 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 +): 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 }); +} diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 8fe5e8ef8..246f86688 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -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 +) { + 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 { + 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 { + 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 { 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 { + 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(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 { 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(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 }; } diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts new file mode 100644 index 000000000..8d512834b --- /dev/null +++ b/server/routers/launcher/launcherScale.ts @@ -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 { + const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(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 { + const counts = await getLauncherScaleCountsForUser( + orgId, + userId, + userRoleIds + ); + + return { + mode: counts.mode, + resourceCount: counts.resourceCount, + siteGroupCount: counts.siteGroupCount, + labelGroupCount: counts.labelGroupCount, + capabilities: buildScaleCapabilities(counts, query) + }; +} diff --git a/server/routers/launcher/listLauncherScale.ts b/server/routers/launcher/listLauncherScale.ts new file mode 100644 index 000000000..2fc907b17 --- /dev/null +++ b/server/routers/launcher/listLauncherScale.ts @@ -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 { + 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" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index e176bae46..8cdcf76ef 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -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, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 0a252d74d..f235e824d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -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; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 9450da153..e4373151f 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -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, diff --git a/server/routers/launcher/upsertLauncherDefaultView.ts b/server/routers/launcher/upsertLauncherDefaultView.ts new file mode 100644 index 000000000..dd9c8bdba --- /dev/null +++ b/server/routers/launcher/upsertLauncherDefaultView.ts @@ -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 { + 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" + ) + ); + } +} diff --git a/server/routers/newt/handleApplyBlueprintMessage.ts b/server/routers/newt/handleApplyBlueprintMessage.ts index 908ff048a..ccc1a6a82 100644 --- a/server/routers/newt/handleApplyBlueprintMessage.ts +++ b/server/routers/newt/handleApplyBlueprintMessage.ts @@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => { source: "NEWT" }); } catch (error) { - logger.error(`Failed to update database from config: ${error}`); + logger.debug(`Failed to update database from config: ${error}`); return { message: { type: "newt/blueprint/results", diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 506c2caa9..6d5ef22cd 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -1,7 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, resourceWhitelist } from "@server/db"; +import { + resources, + resourceWhitelist, + resourcePolicies, + resourcePolicyWhiteList +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -103,40 +108,99 @@ export async function addEmailToResourceWhitelist( ); } - if (!resource.emailWhitelistEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Email whitelist is not enabled for this resource" - ) - ); + // A shared policy takes precedence over the resource's inline + // (default) policy, which takes precedence over the resource's own + // direct whitelist fields. This mirrors the precedence used at + // request time in authWithWhitelist.ts / getResourceAuthInfo.ts. + const policyId = + resource.resourcePolicyId ?? resource.defaultResourcePolicyId; + + if (policyId !== null) { + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Resource policy not found" + ) + ); + } + + if (!policy.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + const existingEntry = await db + .select() + .from(resourcePolicyWhiteList) + .where( + and( + eq( + resourcePolicyWhiteList.resourcePolicyId, + policyId + ), + eq(resourcePolicyWhiteList.email, email) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Email already exists in whitelist" + ) + ); + } + + await db.insert(resourcePolicyWhiteList).values({ + email, + resourcePolicyId: policyId + }); + } else { + if (!resource.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + // Check if email already exists in whitelist + const existingEntry = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Email already exists in whitelist" + ) + ); + } + + await db.insert(resourceWhitelist).values({ + email, + resourceId + }); } - // Check if email already exists in whitelist - const existingEntry = await db - .select() - .from(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, email) - ) - ); - - if (existingEntry.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Email already exists in whitelist" - ) - ); - } - - await db.insert(resourceWhitelist).values({ - email, - resourceId - }); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index 2d882d49e..0bebe6a71 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -96,13 +96,17 @@ export async function getResourceWhitelist( ); } - const isInlinePolicy = - resource.resourcePolicyId === null && - resource.defaultResourcePolicyId !== null; + // A shared policy takes precedence over the resource's inline + // (default) policy, which takes precedence over the resource's own + // direct whitelist fields. This mirrors the precedence used at + // request time in authWithWhitelist.ts / getResourceAuthInfo.ts. + const policyId = + resource.resourcePolicyId ?? resource.defaultResourcePolicyId; - const whitelist = isInlinePolicy - ? await queryPolicyWhitelist(resource.defaultResourcePolicyId!) - : await queryWhitelist(resourceId); + const whitelist = + policyId !== null + ? await queryPolicyWhitelist(policyId) + : await queryWhitelist(resourceId); return response(res, { data: { diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f15a3beda..d1deb9d39 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -616,8 +616,8 @@ export async function listResources( and( inArray(resources.mode, browserGatewayModes), or( - eq(effectiveSso, true), - eq(effectiveWhitelist, true), + effectiveSso, + effectiveWhitelist, not(isNull(effectiveHeaderAuthId)), not(isNull(effectivePincodeId)), not(isNull(effectivePasswordId)) @@ -629,8 +629,8 @@ export async function listResources( conditions.push( and( inArray(resources.mode, browserGatewayModes), - not(eq(effectiveSso, true)), - not(eq(effectiveWhitelist, true)), + not(effectiveSso), + not(effectiveWhitelist), isNull(effectiveHeaderAuthId), isNull(effectivePincodeId), isNull(effectivePasswordId) diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index 615e62438..03d961335 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -1,7 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, resourceWhitelist } from "@server/db"; +import { + resources, + resourceWhitelist, + resourcePolicies, + resourcePolicyWhiteList +} from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -102,44 +107,110 @@ export async function removeEmailFromResourceWhitelist( ); } - if (!resource.emailWhitelistEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Email whitelist is not enabled for this resource" - ) - ); + // A shared policy takes precedence over the resource's inline + // (default) policy, which takes precedence over the resource's own + // direct whitelist fields. This mirrors the precedence used at + // request time in authWithWhitelist.ts / getResourceAuthInfo.ts. + const policyId = + resource.resourcePolicyId ?? resource.defaultResourcePolicyId; + + if (policyId !== null) { + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Resource policy not found" + ) + ); + } + + if (!policy.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + const existingEntry = await db + .select() + .from(resourcePolicyWhiteList) + .where( + and( + eq( + resourcePolicyWhiteList.resourcePolicyId, + policyId + ), + eq(resourcePolicyWhiteList.email, email) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Email not found in whitelist" + ) + ); + } + + await db + .delete(resourcePolicyWhiteList) + .where( + and( + eq( + resourcePolicyWhiteList.resourcePolicyId, + policyId + ), + eq(resourcePolicyWhiteList.email, email) + ) + ); + } else { + if (!resource.emailWhitelistEnabled) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email whitelist is not enabled for this resource" + ) + ); + } + + // Check if email exists in whitelist + const existingEntry = await db + .select() + .from(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Email not found in whitelist" + ) + ); + } + + await db + .delete(resourceWhitelist) + .where( + and( + eq(resourceWhitelist.resourceId, resourceId), + eq(resourceWhitelist.email, email) + ) + ); } - // Check if email exists in whitelist - const existingEntry = await db - .select() - .from(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, email) - ) - ); - - if (existingEntry.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Email not found in whitelist" - ) - ); - } - - await db - .delete(resourceWhitelist) - .where( - and( - eq(resourceWhitelist.resourceId, resourceId), - eq(resourceWhitelist.email, email) - ) - ); - return response(res, { data: {}, success: true, diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 697ae4541..10bf5b480 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -109,13 +109,14 @@ export async function setResourceWhitelist( ); } - const isInlinePolicy = - resource.resourcePolicyId === null && - resource.defaultResourcePolicyId !== null; - - if (isInlinePolicy) { - const policyId = resource.defaultResourcePolicyId!; + // A shared policy takes precedence over the resource's inline + // (default) policy, which takes precedence over the resource's own + // direct whitelist fields. This mirrors the precedence used at + // request time in authWithWhitelist.ts / getResourceAuthInfo.ts. + const policyId = + resource.resourcePolicyId ?? resource.defaultResourcePolicyId; + if (policyId !== null) { const [policy] = await db .select() .from(resourcePolicies) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 22159f1c6..fbba73d4b 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -68,9 +68,9 @@ const updateSiteResourceSchema = z "Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal", example: "service.example.internal" }), - userIds: z.array(z.string()), - roleIds: z.array(z.int()), - clientIds: z.array(z.int()), + userIds: z.array(z.string()).optional(), + roleIds: z.array(z.int()).optional(), + clientIds: z.array(z.int()).optional(), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), @@ -167,6 +167,10 @@ const updateSiteResourceSchema = z ) .refine( (data) => { + // if neither is provided, the existing site associations are left unchanged + if (data.siteIds === undefined && data.siteId === undefined) { + return true; + } return ( (data.siteIds !== undefined && data.siteIds.length > 0) || data.siteId !== undefined @@ -263,7 +267,7 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteIds: siteIdsInput = [], // because it can change + siteIds: siteIdsInput, siteId, niceId, mode, @@ -287,9 +291,14 @@ export async function updateSiteResource( } = parsedBody.data; // Backward compatibility: merge deprecated siteId into siteIds array - const siteIds = [...siteIdsInput]; - if (siteId !== undefined && !siteIds.includes(siteId)) { - siteIds.push(siteId); + const siteIdsProvided = + siteIdsInput !== undefined || siteId !== undefined; + let siteIds: number[] | undefined; + if (siteIdsProvided) { + siteIds = [...(siteIdsInput ?? [])]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } } // Check if site resource exists @@ -356,20 +365,22 @@ export async function updateSiteResource( } // Verify the site exists and belongs to the org - const sitesToAssign = await db - .select() - .from(sites) - .where( - and( - inArray(sites.siteId, siteIds), - eq(sites.orgId, existingSiteResource.orgId) - ) - ); + if (siteIds !== undefined) { + const sitesToAssign = await db + .select() + .from(sites) + .where( + and( + inArray(sites.siteId, siteIds), + eq(sites.orgId, existingSiteResource.orgId) + ) + ); - if (sitesToAssign.length !== siteIds.length) { - return next( - createHttpError(HttpCode.NOT_FOUND, "Some site not found") - ); + if (sitesToAssign.length !== siteIds.length) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Some site not found") + ); + } } // Only check if destination is an IP address @@ -463,7 +474,8 @@ export async function updateSiteResource( } let updatedSiteResource: SiteResource | undefined; - let updatedSiteIds: number[] = []; + // defaults to the existing sites; only overwritten below if siteIds/siteId was provided + let updatedSiteIds: number[] = [...existingSiteIds]; await db.transaction(async (trx) => { // Update the site resource const sshPamSet = @@ -522,81 +534,103 @@ export async function updateSiteResource( //////////////////// update the associations //////////////////// - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq(siteNetworks.networkId, updatedSiteResource.networkId!) - ); - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - updatedSiteIds.push(siteId); - } - - await trx - .delete(clientSiteResources) - .where(eq(clientSiteResources.siteResourceId, siteResourceId)); - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - - await trx - .delete(userSiteResources) - .where(eq(userSiteResources.siteResourceId, siteResourceId)); - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId - })) - ); - } - - // Get all admin role IDs for this org to exclude from deletion - const adminRoles = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) - ); - const adminRoleIds = adminRoles.map((role) => role.roleId); - - if (adminRoleIds.length > 0) { - await trx.delete(roleSiteResources).where( - and( - eq(roleSiteResources.siteResourceId, siteResourceId), - ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role - ) - ); - } else { + if (siteIds !== undefined) { + // delete the site - site resources associations await trx - .delete(roleSiteResources) + .delete(siteNetworks) .where( - eq(roleSiteResources.siteResourceId, siteResourceId) + eq( + siteNetworks.networkId, + updatedSiteResource.networkId! + ) ); + + updatedSiteIds = []; + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + updatedSiteIds.push(siteId); + } } - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId - })) - ); + if (clientIds !== undefined) { + await trx + .delete(clientSiteResources) + .where( + eq(clientSiteResources.siteResourceId, siteResourceId) + ); + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + } + + if (userIds !== undefined) { + await trx + .delete(userSiteResources) + .where( + eq(userSiteResources.siteResourceId, siteResourceId) + ); + + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } + } + + if (roleIds !== undefined) { + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) + ) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceId + ), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(roleSiteResources) + .where( + eq( + roleSiteResources.siteResourceId, + siteResourceId + ) + ); + } + + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); + } } logger.info(`Updated site resource ${siteResourceId}`); diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 7abb1b317..d543b2752 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -82,9 +82,11 @@ export default async function OrgPage(props: OrgPageProps) { orgId={orgId} isAdmin={isAdminOrOwner} views={launcherData.views} + defaultViewOverrides={launcherData.defaultViewOverrides} activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} + scale={launcherData.scale} groups={launcherData.groups} groupsPagination={launcherData.groupsPagination} /> diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index d9e2fefe0..e991d7b97 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -41,7 +41,7 @@ import { import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -137,11 +137,21 @@ function ProxyResourceHttpForm({ }); const [, formAction, saveLoading] = useActionState(onSubmit, null); + const [headersValid, setHeadersValid] = useState(true); async function onSubmit() { const isValid = await form.trigger(); if (!isValid) return; + if (!headersValid) { + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: t("headersValidationError") + }); + return; + } + const data = form.getValues(); const res = await api @@ -318,6 +328,9 @@ function ProxyResourceHttpForm({ onChange={ field.onChange } + onValidityChange={ + setHeadersValid + } rows={4} /> @@ -341,7 +354,7 @@ function ProxyResourceHttpForm({