diff --git a/server/routers/external.ts b/server/routers/external.ts index d2935ff91..2ccd9154f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -62,6 +62,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore"; import { checkRoundTripMessage } from "./ws"; import * as labels from "@server/routers/labels"; import * as aiProvider from "@server/routers/aiProvider"; +import * as redirect from "@server/routers/redirect"; import * as aiBudget from "@server/routers/aiBudget"; import * as virtualApiKey from "@server/routers/virtualApiKey"; import * as certificates from "@server/routers/certificates"; @@ -1622,6 +1623,50 @@ authenticated.delete( aiProvider.deleteAiProvider ); +authenticated.put( + "/org/:orgId/redirect", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createRedirect), + logActionAudit(ActionsEnum.createRedirect), + redirect.createRedirect +); + +authenticated.get( + "/org/:orgId/redirects", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listRedirects), + redirect.listRedirects +); + +authenticated.get( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); +authenticated.get( + "/org/:orgId/redirect/:niceId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getRedirect), + redirect.getRedirect +); + +authenticated.post( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRedirect), + logActionAudit(ActionsEnum.updateRedirect), + redirect.updateRedirect +); + +authenticated.delete( + "/org/:orgId/redirects/:redirectId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteRedirect), + logActionAudit(ActionsEnum.deleteRedirect), + redirect.deleteRedirect +); + authenticated.put( "/ai-provider/:providerId/model", verifyAiProviderAccess, diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts new file mode 100644 index 000000000..c1c94b9ad --- /dev/null +++ b/server/routers/redirect/createRedirect.ts @@ -0,0 +1,172 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains, redirects, resources } from "@server/db"; +import type { Redirect } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { redirectSourcePathSchema } from "@server/routers/redirect/validation"; +import { getUniqueRedirectName } from "@server/db/names"; + +export type CreateRedirectResponse = { + redirect: Redirect; +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty(), + resourceId: z.number().int().positive().optional().nullable(), + domainId: z.string().nonempty().optional().nullable(), + sourcePath: redirectSourcePathSchema, + destinationUrl: z.url().optional().nullable(), + permanent: z.boolean().optional(), + enabled: z.boolean().optional() +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/redirect", + description: "Create a redirect for an organization.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 201: { + description: "Successful response" + } + } +}); + +export async function createRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + name, + resourceId, + domainId, + sourcePath, + destinationUrl, + permanent, + enabled + } = parsedBody.data; + + if (resourceId) { + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.orgId, orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + } + + if (domainId) { + const [domain] = await db + .select({ domainId: domains.domainId }) + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${domainId} not found` + ) + ); + } + } + + const niceId = await getUniqueRedirectName(orgId); + + const [redirect] = await db + .insert(redirects) + .values({ + orgId, + name, + niceId, + resourceId: resourceId ?? null, + domainId: domainId ?? null, + sourcePath, + destinationUrl: destinationUrl ?? null, + permanent: permanent ?? false, + enabled: enabled ?? true + }) + .returning(); + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/deleteRedirect.ts b/server/routers/redirect/deleteRedirect.ts new file mode 100644 index 000000000..90dc57584 --- /dev/null +++ b/server/routers/redirect/deleteRedirect.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Delete a redirect.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function deleteRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, redirectId } = parsedParams.data; + + const [existing] = await db + .select({ redirectId: redirects.redirectId }) + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId} not found` + ) + ); + } + + await db + .delete(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Redirect deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts new file mode 100644 index 000000000..979bf6035 --- /dev/null +++ b/server/routers/redirect/getRedirect.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } from "@server/db"; +import type { Redirect } from "@server/db"; +import response from "@server/lib/response"; +import stoi from "@server/lib/stoi"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; + +export type GetRedirectResponse = { + redirect: Redirect; +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z + .string() + .optional() + .transform(stoi) + .pipe(z.int().positive().optional()) + .optional(), + niceId: z.string().optional() +}); + +async function query(orgId: string, redirectId?: number, niceId?: string) { + if (redirectId) { + const [res] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + return res; + } else if (niceId) { + const [res] = await db + .select() + .from(redirects) + .where( + and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId)) + ) + .limit(1); + return res; + } +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Get a redirect by ID.", + tags: [OpenAPITags.Redirect], + request: { + params: z.object({ + orgId: z.string(), + redirectId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirect/{niceId}", + description: + "Get a redirect by orgId and niceId. NiceId is a readable ID for the redirect and unique on a per org basis.", + tags: [OpenAPITags.Redirect], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function getRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, redirectId, niceId } = parsedParams.data; + + const redirect = await query(orgId, redirectId, niceId); + + if (!redirect) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId || niceId} not found` + ) + ); + } + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/index.ts b/server/routers/redirect/index.ts new file mode 100644 index 000000000..e3ea85f90 --- /dev/null +++ b/server/routers/redirect/index.ts @@ -0,0 +1,5 @@ +export * from "./createRedirect"; +export * from "./listRedirects"; +export * from "./getRedirect"; +export * from "./updateRedirect"; +export * from "./deleteRedirect"; diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts new file mode 100644 index 000000000..4310e9b10 --- /dev/null +++ b/server/routers/redirect/listRedirects.ts @@ -0,0 +1,156 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { redirects, db } from "@server/db"; +import type { Redirect } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq, like, sql } from "drizzle-orm"; +import type { PaginatedResponse } from "@server/types/Pagination"; + +export type ListRedirectsResponse = PaginatedResponse<{ + redirects: Redirect[]; +}>; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listSchema = 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" + }), + query: z.string().optional() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/redirects", + description: "List redirects for an organization.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + query: listSchema + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function listRedirects( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const { pageSize, page, query } = parsedQuery.data; + const conditions = [eq(redirects.orgId, orgId)]; + + if (query) { + conditions.push( + like( + sql`LOWER(${redirects.name})`, + "%" + query.toLowerCase() + "%" + ) + ); + } + + const baseQuery = db + .select() + .from(redirects) + .where(and(...conditions)); + + const countQuery = db.$count( + db + .select() + .from(redirects) + .where(and(...conditions)) + .as("filtered_redirects") + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy(asc(redirects.name)) + ]); + + return response(res, { + data: { + redirects: rows, + pagination: { + total: totalCount, + pageSize, + page + } + }, + success: true, + error: false, + message: "Redirects retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts new file mode 100644 index 000000000..7f10ae69f --- /dev/null +++ b/server/routers/redirect/updateRedirect.ts @@ -0,0 +1,232 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains, redirects, resources } from "@server/db"; +import type { Redirect } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq, ne } from "drizzle-orm"; +import { + redirectNiceIdSchema, + redirectSourcePathSchema +} from "@server/routers/redirect/validation"; + +export type UpdateRedirectResponse = { + redirect: Redirect; +}; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + redirectId: z.coerce.number().int().positive() +}); + +const bodySchema = z.strictObject({ + name: z.string().nonempty().optional(), + niceId: redirectNiceIdSchema.optional(), + resourceId: z.number().int().positive().optional().nullable(), + domainId: z.string().nonempty().optional().nullable(), + sourcePath: redirectSourcePathSchema.optional(), + destinationUrl: z.url().optional().nullable(), + permanent: z.boolean().optional(), + enabled: z.boolean().optional() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/redirects/{redirectId}", + description: "Update a redirect.", + tags: [OpenAPITags.Redirect], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response" + } + } +}); + +export async function updateRedirect( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, redirectId } = parsedParams.data; + const body = parsedBody.data; + + const [existing] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Redirect with ID ${redirectId} not found` + ) + ); + } + + if (body.resourceId) { + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where( + and( + eq(resources.resourceId, body.resourceId), + eq(resources.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${body.resourceId} not found` + ) + ); + } + } + + if (body.domainId) { + const [domain] = await db + .select({ domainId: domains.domainId }) + .from(domains) + .innerJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ) + .where( + and( + eq(domains.domainId, body.domainId), + eq(orgDomains.orgId, existing.orgId) + ) + ) + .limit(1); + + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${body.domainId} not found` + ) + ); + } + } + + if (body.niceId) { + const [existingNiceId] = await db + .select() + .from(redirects) + .where( + and( + eq(redirects.niceId, body.niceId), + eq(redirects.orgId, existing.orgId), + ne(redirects.redirectId, existing.redirectId) // exclude the current redirect from the search + ) + ) + .limit(1); + + if (existingNiceId) { + return next( + createHttpError( + HttpCode.CONFLICT, + `A redirect with niceId "${body.niceId}" already exists` + ) + ); + } + } + + const updateData: Partial = {}; + + if (body.name !== undefined) { + updateData.name = body.name; + } + if (body.niceId !== undefined) { + updateData.niceId = body.niceId; + } + if (body.resourceId !== undefined) { + updateData.resourceId = body.resourceId; + } + if (body.domainId !== undefined) { + updateData.domainId = body.domainId; + } + if (body.sourcePath !== undefined) { + updateData.sourcePath = body.sourcePath; + } + if (body.destinationUrl !== undefined) { + updateData.destinationUrl = body.destinationUrl; + } + if (body.permanent !== undefined) { + updateData.permanent = body.permanent; + } + if (body.enabled !== undefined) { + updateData.enabled = body.enabled; + } + + const [redirect] = await db + .update(redirects) + .set(updateData) + .where( + and( + eq(redirects.redirectId, redirectId), + eq(redirects.orgId, orgId) + ) + ) + .returning(); + + return response(res, { + data: { + redirect + }, + success: true, + error: false, + message: "Redirect updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts new file mode 100644 index 000000000..49fe256bd --- /dev/null +++ b/server/routers/redirect/validation.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const redirectNiceIdSchema = z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ); + +export const redirectSourcePathSchema = z + .string() + .nonempty() + .regex(/^\//, "sourcePath must start with a /") + .default("/*");