From e118e5b047d75b9bb1c878dbda79f10d2d52f50e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 11 Apr 2026 21:03:35 -0700 Subject: [PATCH] add list alises endpoint --- server/routers/external.ts | 6 + server/routers/resource/index.ts | 1 + .../resource/listUserResourceAliases.ts | 262 ++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 server/routers/resource/listUserResourceAliases.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 8914a0251..d7729bca5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -440,6 +440,12 @@ authenticated.get( resource.getUserResources ); +authenticated.get( + "/org/:orgId/user-resource-aliases", + verifyOrgAccess, + resource.listUserResourceAliases +); + authenticated.get( "/org/:orgId/domains", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 3ada13d85..12e98a70d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -22,6 +22,7 @@ export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; export * from "./getUserResources"; +export * from "./listUserResourceAliases"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts new file mode 100644 index 000000000..663700e64 --- /dev/null +++ b/server/routers/resource/listUserResourceAliases.ts @@ -0,0 +1,262 @@ +import { Request, Response, NextFunction } from "express"; +import { + db, + siteResources, + userSiteResources, + roleSiteResources, + userOrgRoles, + userOrgs +} from "@server/db"; +import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { OpenAPITags, registry } from "@server/openApi"; +import { localCache } from "#dynamic/lib/cache"; + +const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; + +function userResourceAliasesCacheKey( + orgId: string, + userId: string, + page: number, + pageSize: number +) { + return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`; +} + +const listUserResourceAliasesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const listUserResourceAliasesQuerySchema = z.object({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }) +}); + +export type ListUserResourceAliasesResponse = PaginatedResponse<{ + aliases: string[]; +}>; + +// registry.registerPath({ +// method: "get", +// path: "/org/{orgId}/user-resource-aliases", +// description: +// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.", +// tags: [OpenAPITags.PrivateResource], +// request: { +// params: z.object({ +// orgId: z.string() +// }), +// query: listUserResourceAliasesQuerySchema +// }, +// responses: {} +// }); + +export async function listUserResourceAliases( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listUserResourceAliasesQuerySchema.safeParse( + req.query + ); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { page, pageSize } = parsedQuery.data; + + const parsedParams = listUserResourceAliasesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + const [userOrg] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrg) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User not in organization") + ); + } + + const cacheKey = userResourceAliasesCacheKey( + orgId, + userId, + page, + pageSize + ); + const cachedData: ListUserResourceAliasesResponse | undefined = + localCache.get(cacheKey); + + if (cachedData) { + return response(res, { + data: cachedData, + success: true, + error: false, + message: "User resource aliases retrieved successfully", + status: HttpCode.OK + }); + } + + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ) + .then((rows) => rows.map((r) => r.roleId)); + + const directSiteResourcesQuery = db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)); + + const roleSiteResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]); + + const [directSiteResourceResults, roleSiteResourceResults] = + await Promise.all([ + directSiteResourcesQuery, + roleSiteResourcesQuery + ]); + + const accessibleSiteResourceIds = [ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]; + + if (accessibleSiteResourceIds.length === 0) { + const data: ListUserResourceAliasesResponse = { + aliases: [], + pagination: { + total: 0, + pageSize, + page + } + }; + localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + return response(res, { + data, + success: true, + error: false, + message: "User resource aliases retrieved successfully", + status: HttpCode.OK + }); + } + + const whereClause = and( + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + eq(siteResources.mode, "host"), + isNotNull(siteResources.alias), + ne(siteResources.alias, ""), + inArray(siteResources.siteResourceId, accessibleSiteResourceIds) + ); + + const baseSelect = () => + db + .select({ alias: siteResources.alias }) + .from(siteResources) + .where(whereClause); + + const countQuery = db.$count(baseSelect().as("filtered_aliases")); + + const [rows, totalCount] = await Promise.all([ + baseSelect() + .orderBy(asc(siteResources.alias)) + .limit(pageSize) + .offset(pageSize * (page - 1)), + countQuery + ]); + + const aliases = rows.map((r) => r.alias as string); + + const data: ListUserResourceAliasesResponse = { + aliases, + pagination: { + total: totalCount, + pageSize, + page + } + }; + localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + + return response(res, { + data, + success: true, + error: false, + message: "User resource aliases retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +}