Compare commits

...

10 Commits

Author SHA1 Message Date
Fred KISSIE 2bccdc33fc 🚧 redirects index page 2026-09-10 02:44:31 +02:00
Fred KISSIE 05d1d4d143 🚧 Redirect cRUD 2026-09-10 00:31:39 +02:00
Fred KISSIE ae7315244f 🗃️ create DB models 2026-09-10 00:17:36 +02:00
Fred KISSIE 241ecc13e2 🚧 wip 2026-09-09 01:43:14 +02:00
Owen 82c5dcf16f Fix tsconfig to use react-jsx 2026-09-08 16:45:56 -04:00
Owen 59f0c90836 Fix circular import 2026-09-08 16:42:22 -04:00
Owen b0e64a5e5a Widen subnet 2026-09-08 16:31:58 -04:00
Owen 733d3ece0e Quiet up error logs 2026-09-08 10:01:59 -04:00
Owen 59b228ce39 Quiet log message 2026-09-08 09:26:57 -04:00
Owen 080bcbaf97 Use endpoint instead of reachableAt for remote nodes 2026-09-07 11:55:45 -04:00
25 changed files with 1072 additions and 73 deletions
+1
View File
@@ -2095,6 +2095,7 @@
"resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarRedirects": "Redirects",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
"sidebarAllUsers": "All Users",
+6 -1
View File
@@ -205,7 +205,12 @@ export enum ActionsEnum {
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey"
updateVirtualApiKey = "updateVirtualApiKey",
createRedirect = "createRedirect",
deleteRedirect = "deleteRedirect",
getRedirect = "getRedirect",
listRedirects = "listRedirects",
updateRedirect = "updateRedirect"
}
export async function checkUserActionPermission(
+25
View File
@@ -4,6 +4,7 @@ import {
aiProviders,
clients,
db,
redirects,
resourcePolicies,
resources,
siteResources
@@ -140,6 +141,30 @@ export async function getUniqueProviderName(orgId: string): Promise<string> {
}
}
export async function getUniqueRedirectName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const redirectCount = await db
.select({
niceId: redirects.niceId,
orgId: redirects.orgId
})
.from(redirects)
.where(and(eq(redirects.niceId, name), eq(redirects.orgId, orgId)));
if (redirectCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
+22
View File
@@ -227,6 +227,27 @@ export const resources = pgTable(
]
);
export const redirects = pgTable("redirects", {
redirectId: serial("redirectId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
sourcePath: varchar("sourcePath").notNull(),
destinationUrl: varchar("destinationUrl"),
permanent: boolean("permanent").notNull().default(false),
enabled: boolean("enabled").notNull().default(true)
});
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
@@ -2065,6 +2086,7 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
+24
View File
@@ -243,6 +243,29 @@ export const resources = sqliteTable(
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const redirects = sqliteTable("redirects", {
redirectId: integer("redirectId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
sourcePath: text("sourcePath").notNull(),
destinationUrl: text("destinationUrl"),
permanent: integer("permanent", { mode: "boolean" })
.notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
@@ -2104,6 +2127,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
+1 -1
View File
@@ -71,7 +71,7 @@ export async function withRetry<T>(
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
+2 -2
View File
@@ -348,8 +348,8 @@ export const configSchema = z
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
subnet_group: z.string().optional().default("100.89.137.0/18"),
block_size: z.number().positive().gt(0).optional().default(22),
site_block_size: z
.number()
.positive()
+2 -1
View File
@@ -32,7 +32,8 @@ export enum OpenAPITags {
AiProvider = "AI Provider",
AiModel = "AI Model",
AiBudget = "AI Budget",
VirtualApiKey = "Virtual API Key"
VirtualApiKey = "Virtual API Key",
Redirect = "Redirect"
}
// Order here controls the order tags are displayed in Swagger UI
+6 -1
View File
@@ -2478,7 +2478,12 @@ hybridRouter.post(
destinations: destinations
});
} catch (error) {
logger.error(error);
if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -0,0 +1,23 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { EventEmitter } from "events";
export interface ExitNodeOnlineEvent {
exitNodeId: number;
endpoint: string;
}
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
export const exitNodeEvents = new EventEmitter();
@@ -12,11 +12,27 @@
*/
import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db";
import { db, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
// import { sendToClient } from "#private/routers/ws";
import { sendToClient } from "../ws";
import {
exitNodeEvents,
EXIT_NODE_ONLINE_EVENT,
ExitNodeOnlineEvent
} from "./exitNodeEvents";
exitNodeEvents.on(
EXIT_NODE_ONLINE_EVENT,
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
logger.error("Failed to schedule exit node reconnect", {
error
});
});
}
);
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect {
startTime: number;
reachableAt: string;
endpoint: string;
}
// In-memory tracking for this node
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
*/
export async function scheduleExitNodeReconnect(
exitNodeId: number,
reachableAt: string
endpoint: string
): Promise<void> {
logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
);
const entry: PendingReconnect = {
startTime: Date.now(),
reachableAt
endpoint
};
pendingReconnects.set(exitNodeId, entry);
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
);
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt",
reachableAt
"endpoint",
endpoint
);
}
}
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
`${REDIS_HASH_PREFIX}${id}`,
"startTime"
);
const reachableAt = await redisManager.hget(
const endpoint = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"reachableAt"
"endpoint"
);
if (startTimeStr && reachableAt) {
if (startTimeStr && endpoint) {
toProcess.set(id, {
startTime: parseInt(startTimeStr, 10),
reachableAt
endpoint
});
}
}
@@ -135,7 +151,7 @@ async function processPendingReconnects(): Promise<void> {
}
// Check if the exit node HTTP endpoint is reachable
const pingUrl = `${entry.reachableAt}/ping`;
const pingUrl = `http://${entry.endpoint}/ping`;
try {
await axios.get(pingUrl, { timeout: 5000 });
} catch {
@@ -150,47 +166,47 @@ async function processPendingReconnects(): Promise<void> {
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
);
// await sendReconnectToNewts(exitNodeId);
await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId);
}
}
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
// try {
// const connectedNewts = await db
// .select({ newtId: newts.newtId })
// .from(newts)
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
// .where(eq(sites.exitNodeId, exitNodeId));
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try {
const connectedNewts = await db
.select({ newtId: newts.newtId })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId));
// if (connectedNewts.length === 0) {
// logger.debug(
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
// );
// return;
// }
if (connectedNewts.length === 0) {
logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
);
return;
}
// logger.info(
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
// );
logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
);
// const reconnectMessage = {
// type: "newt/wg/reconnect",
// data: {}
// };
const reconnectMessage = {
type: "newt/wg/reconnect",
data: {}
};
// await Promise.allSettled(
// connectedNewts.map(({ newtId }) =>
// sendToClient(newtId, reconnectMessage)
// )
// );
// } catch (error) {
// logger.error(
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
// { error }
// );
// }
// }
await Promise.allSettled(
connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage)
)
);
} catch (error) {
logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error }
);
}
}
async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId);
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
/**
* Handles ping messages from clients and responds with pong
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
try {
// Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1);
@@ -55,12 +55,14 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
scheduleExitNodeReconnect(
remoteExitNode.exitNodeId,
currentExitNode.reachableAt
).catch((error) => {
logger.error("Failed to schedule exit node reconnect", { error });
if (
currentExitNode &&
!currentExitNode.online &&
currentExitNode.endpoint
) {
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
exitNodeId: remoteExitNode.exitNodeId,
endpoint: currentExitNode.endpoint
});
}
} catch (error) {
+45
View File
@@ -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,
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource not found`
);
await sendCancel();
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (currentResourceAssociationCaches.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!resource.networkId) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
await sendCancel();
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (sitesToProcess.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
await sendCancel();
return;
}
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!site.exitNodeId) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!handshakeInitiated) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
);
await sendCancel();
+172
View File
@@ -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<any> {
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<CreateRedirectResponse>(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")
);
}
}
+92
View File
@@ -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<any> {
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")
);
}
}
+135
View File
@@ -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<any> {
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<GetRedirectResponse>(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")
);
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./createRedirect";
export * from "./listRedirects";
export * from "./getRedirect";
export * from "./updateRedirect";
export * from "./deleteRedirect";
+156
View File
@@ -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<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.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<any> {
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<ListRedirectsResponse>(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")
);
}
}
+232
View File
@@ -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<any> {
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<typeof redirects.$inferInsert> = {};
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<UpdateRedirectResponse>(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")
);
}
}
+16
View File
@@ -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("/*");
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function OrgLayout(props: {
subscriptionStatus = subRes.data.data;
} catch (error) {
// If subscription fetch fails, keep subscriptionStatus as null
console.error("Failed to fetch subscription status:", error);
// console.error("Failed to fetch subscription status:", error);
}
}
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Redirects"
};
type RedirectIndexPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ApiKeysPage(props: RedirectIndexPageProps) {
const params = await props.params;
const t = await getTranslations();
return null;
}
+6
View File
@@ -25,6 +25,7 @@ import {
MonitorUp,
Plug,
ReceiptText,
Repeat,
ScanEye,
Server,
Settings,
@@ -323,6 +324,11 @@ export const orgNavSections = (
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
+1 -1
View File
@@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@server/*": [