mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 13:31:27 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c6311d9bd | |||
| 17375348b0 | |||
| e7fdbf9e85 | |||
| cddb5ecc3d |
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:24.18.1-alpine
|
||||
FROM node:26.8.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+1
-46
@@ -2095,7 +2095,6 @@
|
||||
"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",
|
||||
@@ -4364,49 +4363,5 @@
|
||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||
"redirectsTitle": "Manage Redirects",
|
||||
"redirectsDescription": "Forward requests from a path on your domains or resources to another URL",
|
||||
"redirectsSearch": "Search redirects...",
|
||||
"redirectAdd": "Add Redirect",
|
||||
"redirectSource": "Source",
|
||||
"redirectAttachedTo": "Attached To",
|
||||
"redirectType": "Type",
|
||||
"redirectTypePermanent": "Permanent (301)",
|
||||
"redirectTypeTemporary": "Temporary (302)",
|
||||
"redirectUpdated": "Redirect updated successfully",
|
||||
"redirectErrorUpdate": "Failed to update redirect",
|
||||
"redirectDeleted": "Redirect deleted successfully",
|
||||
"redirectErrorDelete": "Failed to delete redirect",
|
||||
"redirectDelete": "Delete Redirect",
|
||||
"redirectDeleteConfirm": "Confirm Delete Redirect",
|
||||
"redirectQuestionRemove": "Are you sure you want to remove this redirect?",
|
||||
"redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.",
|
||||
"redirectDestinationDomain": "Destination Domain",
|
||||
"redirectDestinationDomainDescription": "The domain requests are sent to, such as example.com",
|
||||
"redirectDestinationDomainRequired": "Enter a destination domain",
|
||||
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
|
||||
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
|
||||
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
|
||||
"redirectCreate": "Create Redirect",
|
||||
"redirectCreateDescription": "Forward requests matching a path to another URL",
|
||||
"redirectEditDescription": "Update how this redirect forwards incoming requests",
|
||||
"redirectGoBack": "Back to Redirects",
|
||||
"redirectCreated": "Redirect created successfully",
|
||||
"redirectErrorCreate": "Failed to create redirect",
|
||||
"redirectSettings": "Redirect Settings",
|
||||
"selectedRedirectDomain": "Selected Domain",
|
||||
"selectedRedirectResource": "Selected Resource",
|
||||
"redirectResourceNoDomain": "This resource has no domain",
|
||||
"redirectSettingsGeneralDescription": "Configure the basic redirect settings",
|
||||
"redirectSettingsDescription": "Configure where requests come from and where they are sent",
|
||||
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
|
||||
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
|
||||
"redirectAttachDomain": "Domain",
|
||||
"redirectAttachResource": "Resource",
|
||||
"redirectDomainRequired": "Select a domain to attach this redirect to",
|
||||
"redirectResourceRequired": "Select a resource to attach this redirect to",
|
||||
"redirectPermanent": "Permanent Redirect",
|
||||
"redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.",
|
||||
"redirectDangerSectionDescription": "Permanently remove this redirect. This cannot be undone."
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
||||
}
|
||||
|
||||
@@ -205,12 +205,7 @@ export enum ActionsEnum {
|
||||
deleteVirtualApiKey = "deleteVirtualApiKey",
|
||||
getVirtualApiKey = "getVirtualApiKey",
|
||||
listVirtualApiKeys = "listVirtualApiKeys",
|
||||
updateVirtualApiKey = "updateVirtualApiKey",
|
||||
createRedirect = "createRedirect",
|
||||
deleteRedirect = "deleteRedirect",
|
||||
getRedirect = "getRedirect",
|
||||
listRedirects = "listRedirects",
|
||||
updateRedirect = "updateRedirect"
|
||||
updateVirtualApiKey = "updateVirtualApiKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
aiProviders,
|
||||
clients,
|
||||
db,
|
||||
redirects,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
siteResources
|
||||
@@ -141,30 +140,6 @@ 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> {
|
||||
|
||||
@@ -227,38 +227,6 @@ 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(),
|
||||
subdomain: varchar("subdomain"),
|
||||
destinationDomain: varchar("destinationDomain").notNull(),
|
||||
pathMatchType: varchar("pathMatchType")
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
.notNull()
|
||||
.default("regex"), // exact, prefix, regex
|
||||
matchPath: varchar("matchPath").notNull().default(".*"),
|
||||
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
|
||||
// else, the original path will be kept
|
||||
rewritePathType: varchar("rewritePathType").$type<
|
||||
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||
>(), // exact, prefix, regex, stripPrefix
|
||||
|
||||
permanent: boolean("permanent").notNull().default(false),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
});
|
||||
|
||||
export const resourceAiProviders = pgTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
@@ -2097,7 +2065,6 @@ 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>;
|
||||
|
||||
@@ -243,40 +243,6 @@ 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(),
|
||||
subdomain: text("subdomain"),
|
||||
destinationDomain: text("destinationDomain").notNull(),
|
||||
pathMatchType: text("pathMatchType")
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
.notNull()
|
||||
.default("regex"), // exact, prefix, regex
|
||||
matchPath: text("matchPath").notNull().default("*"),
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
|
||||
// else, the original path will be kept
|
||||
rewritePathType: text("rewritePathType").$type<
|
||||
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||
>(), // exact, prefix, regex, stripPrefix
|
||||
|
||||
permanent: integer("permanent", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
});
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
@@ -2138,7 +2104,6 @@ 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>;
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||
{ code: error?.code ?? error?.cause?.code }
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
@@ -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/18"),
|
||||
block_size: z.number().positive().gt(0).optional().default(22),
|
||||
subnet_group: z.string().optional().default("100.89.137.0/20"),
|
||||
block_size: z.number().positive().gt(0).optional().default(24),
|
||||
site_block_size: z
|
||||
.number()
|
||||
.positive()
|
||||
|
||||
+1
-2
@@ -32,8 +32,7 @@ export enum OpenAPITags {
|
||||
AiProvider = "AI Provider",
|
||||
AiModel = "AI Model",
|
||||
AiBudget = "AI Budget",
|
||||
VirtualApiKey = "Virtual API Key",
|
||||
Redirect = "Redirect"
|
||||
VirtualApiKey = "Virtual API Key"
|
||||
}
|
||||
|
||||
// Order here controls the order tags are displayed in Swagger UI
|
||||
|
||||
@@ -2478,12 +2478,7 @@ hybridRouter.post(
|
||||
destinations: destinations
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof Error &&
|
||||
error.message === "Exit node not allowed"
|
||||
)) {
|
||||
logger.error(error);
|
||||
}
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* 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,27 +12,11 @@
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
// import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
@@ -42,7 +26,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
endpoint: string;
|
||||
reachableAt: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
@@ -56,15 +40,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
endpoint: string
|
||||
reachableAt: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
endpoint
|
||||
reachableAt
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
@@ -79,8 +63,8 @@ export async function scheduleExitNodeReconnect(
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"endpoint",
|
||||
endpoint
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,14 +101,14 @@ async function processPendingReconnects(): Promise<void> {
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const endpoint = await redisManager.hget(
|
||||
const reachableAt = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"endpoint"
|
||||
"reachableAt"
|
||||
);
|
||||
if (startTimeStr && endpoint) {
|
||||
if (startTimeStr && reachableAt) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
endpoint
|
||||
reachableAt
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -151,7 +135,7 @@ async function processPendingReconnects(): Promise<void> {
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `http://${entry.endpoint}/ping`;
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
@@ -166,47 +150,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 { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
|
||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||
|
||||
/**
|
||||
* 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, endpoint: exitNodes.endpoint })
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
@@ -55,14 +55,12 @@ 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.endpoint
|
||||
) {
|
||||
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
|
||||
exitNodeId: remoteExitNode.exitNodeId,
|
||||
endpoint: currentExitNode.endpoint
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -62,7 +62,6 @@ 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";
|
||||
@@ -1623,50 +1622,6 @@ 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.warn(
|
||||
logger.error(
|
||||
`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.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`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.warn(
|
||||
logger.error(
|
||||
`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.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
@@ -164,7 +164,9 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
@@ -191,7 +193,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -203,7 +205,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
@@ -227,7 +229,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
}
|
||||
|
||||
if (!handshakeInitiated) {
|
||||
logger.warn(
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
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 {
|
||||
redirectMatchPathSchema,
|
||||
redirectPathMatchTypeSchema,
|
||||
redirectRewritePathSchema,
|
||||
redirectRewritePathTypeSchema
|
||||
} 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(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
destinationDomain: z.string().nonempty(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema,
|
||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||
permanent: z.boolean().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
}).refine(
|
||||
(data) =>
|
||||
// stripPrefix removes the matched prefix and needs no replacement
|
||||
// value; every other rewrite type is meaningless without one.
|
||||
!data.rewritePathType ||
|
||||
data.rewritePathType === "stripPrefix" ||
|
||||
Boolean(data.rewritePath),
|
||||
{
|
||||
message:
|
||||
"rewritePath is required unless rewritePathType is stripPrefix",
|
||||
path: ["rewritePath"]
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
subdomain,
|
||||
destinationDomain,
|
||||
pathMatchType,
|
||||
matchPath,
|
||||
rewritePath,
|
||||
rewritePathType,
|
||||
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,
|
||||
subdomain: subdomain ?? null,
|
||||
destinationDomain,
|
||||
pathMatchType: pathMatchType ?? "regex",
|
||||
matchPath,
|
||||
rewritePath: rewritePath ?? null,
|
||||
rewritePathType: rewritePathType ?? 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { domains, redirects, resources, db } 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: {
|
||||
redirectId: number;
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
permanent: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
resourceSsl: boolean | null;
|
||||
resourceWildcard: boolean | null;
|
||||
domainId: string | null;
|
||||
baseDomain: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const redirectColumns = {
|
||||
redirectId: redirects.redirectId,
|
||||
orgId: redirects.orgId,
|
||||
niceId: redirects.niceId,
|
||||
name: redirects.name,
|
||||
subdomain: redirects.subdomain,
|
||||
destinationDomain: redirects.destinationDomain,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
rewritePath: redirects.rewritePath,
|
||||
rewritePathType: redirects.rewritePathType,
|
||||
permanent: redirects.permanent,
|
||||
enabled: redirects.enabled,
|
||||
resourceId: redirects.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId,
|
||||
resourceFullDomain: resources.fullDomain,
|
||||
resourceSsl: resources.ssl,
|
||||
resourceWildcard: resources.wildcard,
|
||||
domainId: redirects.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
};
|
||||
|
||||
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(redirectColumns)
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.redirectId, redirectId),
|
||||
eq(redirects.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return res;
|
||||
} else if (niceId) {
|
||||
const [res] = await db
|
||||
.select(redirectColumns)
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||
.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./createRedirect";
|
||||
export * from "./listRedirects";
|
||||
export * from "./getRedirect";
|
||||
export * from "./updateRedirect";
|
||||
export * from "./deleteRedirect";
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { domains, redirects, resources, 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, asc, eq, like, or, sql } from "drizzle-orm";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type ListRedirectsResponse = PaginatedResponse<{
|
||||
redirects: Array<{
|
||||
redirectId: number;
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
permanent: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
domainId: string | null;
|
||||
baseDomain: string | null;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
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) {
|
||||
const term = "%" + query.toLowerCase() + "%";
|
||||
conditions.push(
|
||||
or(
|
||||
like(sql`LOWER(${redirects.name})`, term),
|
||||
like(sql`LOWER(${redirects.matchPath})`, term),
|
||||
like(sql`LOWER(${redirects.destinationDomain})`, term)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
redirectId: redirects.redirectId,
|
||||
orgId: redirects.orgId,
|
||||
niceId: redirects.niceId,
|
||||
name: redirects.name,
|
||||
subdomain: redirects.subdomain,
|
||||
destinationDomain: redirects.destinationDomain,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
rewritePath: redirects.rewritePath,
|
||||
rewritePathType: redirects.rewritePathType,
|
||||
permanent: redirects.permanent,
|
||||
enabled: redirects.enabled,
|
||||
resourceId: redirects.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId,
|
||||
resourceFullDomain: resources.fullDomain,
|
||||
domainId: redirects.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
})
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||
.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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
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,
|
||||
redirectMatchPathSchema,
|
||||
redirectPathMatchTypeSchema,
|
||||
redirectRewritePathSchema,
|
||||
redirectRewritePathTypeSchema
|
||||
} 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(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
destinationDomain: z.string().nonempty().optional(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional(),
|
||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||
rewritePathType: redirectRewritePathTypeSchema.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.subdomain !== undefined) {
|
||||
updateData.subdomain = body.subdomain;
|
||||
}
|
||||
if (body.destinationDomain !== undefined) {
|
||||
updateData.destinationDomain = body.destinationDomain;
|
||||
}
|
||||
if (body.pathMatchType !== undefined) {
|
||||
updateData.pathMatchType = body.pathMatchType;
|
||||
}
|
||||
if (body.matchPath !== undefined) {
|
||||
updateData.matchPath = body.matchPath;
|
||||
}
|
||||
if (body.rewritePath !== undefined) {
|
||||
updateData.rewritePath = body.rewritePath;
|
||||
}
|
||||
if (body.rewritePathType !== undefined) {
|
||||
updateData.rewritePathType = body.rewritePathType;
|
||||
}
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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 redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]);
|
||||
|
||||
export const redirectRewritePathTypeSchema = z.enum([
|
||||
"exact",
|
||||
"prefix",
|
||||
"regex",
|
||||
"stripPrefix"
|
||||
]);
|
||||
|
||||
export const redirectMatchPathSchema = z.string().nonempty().default("*");
|
||||
|
||||
export const redirectRewritePathSchema = z.string().nonempty();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import RedirectForm from "@app/components/RedirectForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { GetRedirectResponse } from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edit Redirect"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type EditRedirectPageProps = {
|
||||
params: Promise<{ orgId: string; niceId: string }>;
|
||||
};
|
||||
|
||||
export default async function EditRedirectPage(props: EditRedirectPageProps) {
|
||||
const { orgId, niceId } = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let redirect: GetRedirectResponse["redirect"];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetRedirectResponse>>(
|
||||
`/org/${orgId}/redirect/${niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
redirect = res.data.data.redirect;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// The resource selector needs the resource's display fields up front so the
|
||||
// trigger shows a name instead of a bare id before the list query resolves.
|
||||
const initialResource =
|
||||
redirect.resourceId && redirect.resourceNiceId
|
||||
? {
|
||||
resourceId: redirect.resourceId,
|
||||
niceId: redirect.resourceNiceId,
|
||||
name: redirect.resourceName ?? redirect.resourceNiceId,
|
||||
fullDomain: redirect.resourceFullDomain,
|
||||
ssl: redirect.resourceSsl ?? false,
|
||||
wildcard: redirect.resourceWildcard ?? false
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={redirect.name}
|
||||
description={t("redirectEditDescription")}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/redirects`}>
|
||||
{t("redirectGoBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RedirectForm
|
||||
orgId={orgId}
|
||||
redirect={redirect}
|
||||
initialResource={initialResource}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import RedirectForm from "@app/components/RedirectForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Redirect"
|
||||
};
|
||||
|
||||
type CreateRedirectPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function CreateRedirectPage(
|
||||
props: CreateRedirectPageProps
|
||||
) {
|
||||
const { orgId } = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={t("redirectCreate")}
|
||||
description={t("redirectCreateDescription")}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/redirects`}>
|
||||
{t("redirectGoBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RedirectForm orgId={orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import RedirectsTable from "@app/components/RedirectsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { ListRedirectsResponse } from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Redirects"
|
||||
};
|
||||
|
||||
type RedirectIndexPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
const { orgId } = await props.params;
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const t = await getTranslations();
|
||||
|
||||
let redirects: ListRedirectsResponse["redirects"] = [];
|
||||
let pagination: ListRedirectsResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListRedirectsResponse>>(
|
||||
`/org/${orgId}/redirects?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
redirects = responseData.redirects;
|
||||
pagination = responseData.pagination;
|
||||
} catch {
|
||||
// empty list on error
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("redirectsTitle")}
|
||||
description={t("redirectsDescription")}
|
||||
/>
|
||||
|
||||
<RedirectsTable
|
||||
orgId={orgId}
|
||||
redirects={redirects.map((redirect) => ({
|
||||
redirectId: redirect.redirectId,
|
||||
niceId: redirect.niceId,
|
||||
name: redirect.name,
|
||||
subdomain: redirect.subdomain,
|
||||
destinationDomain: redirect.destinationDomain,
|
||||
pathMatchType: redirect.pathMatchType,
|
||||
matchPath: redirect.matchPath,
|
||||
rewritePath: redirect.rewritePath,
|
||||
rewritePathType: redirect.rewritePathType,
|
||||
permanent: redirect.permanent,
|
||||
enabled: redirect.enabled,
|
||||
resourceId: redirect.resourceId,
|
||||
resourceName: redirect.resourceName,
|
||||
resourceNiceId: redirect.resourceNiceId,
|
||||
resourceFullDomain: redirect.resourceFullDomain,
|
||||
domainId: redirect.domainId,
|
||||
baseDomain: redirect.baseDomain
|
||||
}))}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
MonitorUp,
|
||||
Plug,
|
||||
ReceiptText,
|
||||
Repeat,
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -324,11 +323,6 @@ 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,795 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type {
|
||||
CreateRedirectResponse,
|
||||
GetRedirectResponse
|
||||
} from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { Plus } from "lucide-react";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import Link from "next/link";
|
||||
|
||||
const DEFAULT_MATCH_PATH = ".*";
|
||||
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
||||
|
||||
export type ExistingRedirect = GetRedirectResponse["redirect"];
|
||||
|
||||
type RedirectFormProps = {
|
||||
orgId: string;
|
||||
/** Omit to create a new redirect. */
|
||||
redirect?: ExistingRedirect;
|
||||
/** Name/domain of the resource the redirect is attached to, when there is one. */
|
||||
initialResource?: SelectedResource | null;
|
||||
};
|
||||
|
||||
export default function RedirectForm({
|
||||
orgId,
|
||||
redirect,
|
||||
initialResource = null
|
||||
}: RedirectFormProps) {
|
||||
const isEditing = Boolean(redirect);
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<SelectedResource | null>(initialResource);
|
||||
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: t("nameRequired") }),
|
||||
attachTo: z.enum(["domain", "resource"]),
|
||||
domainId: z.string().nullable(),
|
||||
subdomain: z.string().nullable(),
|
||||
resourceId: z.number().int().positive().nullable(),
|
||||
destinationDomain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, {
|
||||
message: t("redirectDestinationDomainRequired")
|
||||
}),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]),
|
||||
matchPath: z.string().trim().min(1),
|
||||
rewritePath: z.string().nullable(),
|
||||
rewritePathType: z
|
||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||
.nullable(),
|
||||
permanent: z.boolean(),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.attachTo === "domain" && !data.domainId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("redirectDomainRequired"),
|
||||
path: ["domainId"]
|
||||
});
|
||||
}
|
||||
if (data.attachTo === "resource" && !data.resourceId) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("redirectResourceRequired"),
|
||||
path: ["resourceId"]
|
||||
});
|
||||
}
|
||||
// stripPrefix drops the matched prefix outright, so it is
|
||||
// the one rewrite type that needs no replacement value.
|
||||
if (
|
||||
data.rewritePathType &&
|
||||
data.rewritePathType !== "stripPrefix" &&
|
||||
!data.rewritePath
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("redirectRewritePathRequired"),
|
||||
path: ["rewritePath"]
|
||||
});
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
type RedirectFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<RedirectFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: redirect?.name ?? "",
|
||||
attachTo: redirect?.resourceId ? "resource" : "domain",
|
||||
domainId: redirect?.domainId ?? null,
|
||||
subdomain: redirect?.subdomain ?? null,
|
||||
resourceId: redirect?.resourceId ?? null,
|
||||
destinationDomain: redirect?.destinationDomain ?? "",
|
||||
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
||||
matchPath: redirect?.matchPath ?? DEFAULT_MATCH_PATH,
|
||||
rewritePath: redirect?.rewritePath ?? null,
|
||||
rewritePathType: redirect?.rewritePathType ?? null,
|
||||
permanent: redirect?.permanent ?? false,
|
||||
enabled: redirect?.enabled ?? true
|
||||
}
|
||||
});
|
||||
|
||||
const attachTo = form.watch("attachTo");
|
||||
const pathMatchType = form.watch("pathMatchType");
|
||||
const rewritePath = form.watch("rewritePath");
|
||||
const rewritePathType = form.watch("rewritePathType");
|
||||
// stripPrefix is a valid rewrite with no path value, so it counts as set.
|
||||
const hasRewrite =
|
||||
Boolean(rewritePath) || rewritePathType === "stripPrefix";
|
||||
|
||||
async function onSubmit(values: RedirectFormValues) {
|
||||
setSaveLoading(true);
|
||||
|
||||
// Only one of the two attachment points is ever persisted; clear the
|
||||
// other so switching between them doesn't leave a stale reference.
|
||||
const body = {
|
||||
name: values.name.trim(),
|
||||
domainId: values.attachTo === "domain" ? values.domainId : null,
|
||||
subdomain:
|
||||
values.attachTo === "domain" ? values.subdomain || null : null,
|
||||
resourceId:
|
||||
values.attachTo === "resource" ? values.resourceId : null,
|
||||
destinationDomain: values.destinationDomain.trim(),
|
||||
pathMatchType: values.pathMatchType,
|
||||
matchPath: values.matchPath.trim(),
|
||||
rewritePath: values.rewritePath?.trim() || null,
|
||||
rewritePathType: values.rewritePathType,
|
||||
permanent: values.permanent,
|
||||
enabled: values.enabled
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
await api.post(
|
||||
`/org/${orgId}/redirects/${redirect!.redirectId}`,
|
||||
body
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
const res = await api.put<
|
||||
AxiosResponse<CreateRedirectResponse>
|
||||
>(`/org/${orgId}/redirect`, body);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectCreated")
|
||||
});
|
||||
router.push(
|
||||
`/${orgId}/settings/redirects/${res.data.data.redirect.niceId}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectDeleted")
|
||||
});
|
||||
router.push(`/${orgId}/settings/redirects`);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("redirectErrorDelete"),
|
||||
description: formatAxiosError(e, t("redirectErrorDelete"))
|
||||
});
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={setIsDeleteModalOpen}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("redirectQuestionRemove")}</p>
|
||||
<p>{t("redirectMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("redirectDeleteConfirm")}
|
||||
onConfirm={onDelete}
|
||||
string={redirect!.name}
|
||||
title={t("redirectDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("general")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectSettingsGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="redirect-form"
|
||||
>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-enabled"
|
||||
label={t(
|
||||
"enabled"
|
||||
)}
|
||||
description={t(
|
||||
"redirectEnabledDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"redirectAttachedTo"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="domain">
|
||||
{t(
|
||||
"redirectAttachDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="resource">
|
||||
{t(
|
||||
"redirectAttachResource"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectAttachedToDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{attachTo === "domain" ? (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={1}
|
||||
hideFreeDomain
|
||||
defaultDomainId={
|
||||
redirect?.domainId
|
||||
}
|
||||
allowWildcard
|
||||
defaultSubdomain={
|
||||
redirect?.subdomain
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res?.domainId ??
|
||||
null,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res?.subdomain ||
|
||||
null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
) : (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"selectedRedirectResource"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedResource?.name ??
|
||||
t(
|
||||
"resourceSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<ResourceSelector
|
||||
orgId={
|
||||
orgId
|
||||
}
|
||||
selectedResource={
|
||||
selectedResource
|
||||
}
|
||||
onSelectResource={(
|
||||
resource
|
||||
) => {
|
||||
setSelectedResource(
|
||||
resource
|
||||
);
|
||||
field.onChange(
|
||||
resource.resourceId
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{attachTo === "resource" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourceDomain")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
disabled
|
||||
readOnly
|
||||
value={
|
||||
selectedResource?.fullDomain ??
|
||||
""
|
||||
}
|
||||
placeholder={
|
||||
selectedResource
|
||||
? t(
|
||||
"redirectResourceNoDomain"
|
||||
)
|
||||
: t(
|
||||
"resourceSelect"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"redirectDestinationDomain"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectDestinationDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="matchPath"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("matchPath")}
|
||||
</FormLabel>
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: field.value,
|
||||
pathMatchType:
|
||||
pathMatchType
|
||||
}}
|
||||
onChange={(
|
||||
config
|
||||
) => {
|
||||
// matchPath and
|
||||
// pathMatchType are
|
||||
// NOT NULL, so a
|
||||
// clear falls back
|
||||
// to the defaults
|
||||
// rather than null.
|
||||
field.onChange(
|
||||
config.path ||
|
||||
DEFAULT_MATCH_PATH
|
||||
);
|
||||
form.setValue(
|
||||
"pathMatchType",
|
||||
(config.pathMatchType as
|
||||
| "exact"
|
||||
| "prefix"
|
||||
| "regex") ||
|
||||
DEFAULT_PATH_MATCH_TYPE
|
||||
);
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||
>
|
||||
<PathMatchDisplay
|
||||
value={{
|
||||
path: field.value,
|
||||
pathMatchType:
|
||||
pathMatchType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectMatchPathDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rewritePath"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("rewritePath")}
|
||||
</FormLabel>
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath:
|
||||
field.value,
|
||||
rewritePathType:
|
||||
rewritePathType
|
||||
}}
|
||||
onChange={(
|
||||
config
|
||||
) => {
|
||||
field.onChange(
|
||||
config.rewritePath ||
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"rewritePathType",
|
||||
(config.rewritePathType as
|
||||
| "exact"
|
||||
| "prefix"
|
||||
| "regex"
|
||||
| "stripPrefix"
|
||||
| null) ??
|
||||
null
|
||||
);
|
||||
}}
|
||||
trigger={
|
||||
hasRewrite ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||
>
|
||||
<PathRewriteDisplay
|
||||
value={{
|
||||
rewritePath:
|
||||
field.value,
|
||||
rewritePathType:
|
||||
rewritePathType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"rewritePath"
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectRewritePathDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="permanent"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-permanent"
|
||||
label={t(
|
||||
"redirectPermanent"
|
||||
)}
|
||||
description={t(
|
||||
"redirectPermanentDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{isEditing && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("dangerSection")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectDangerSectionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
loading={deleteLoading}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{t("redirectDelete")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/redirects`}>
|
||||
{t("cancel")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="redirect-form"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
>
|
||||
{isEditing ? t("saveSettings") : t("redirectAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { ArrowRight, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export type RedirectRow = {
|
||||
redirectId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationDomain: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
permanent: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
domainId: string | null;
|
||||
baseDomain: string | null;
|
||||
};
|
||||
|
||||
type RedirectsTableProps = {
|
||||
redirects: RedirectRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function RedirectsTable({
|
||||
redirects,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: RedirectsTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [rows, setRows] = useState(redirects);
|
||||
const [selected, setSelected] = useState<RedirectRow | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setRows(redirects);
|
||||
}, [redirects]);
|
||||
|
||||
function refreshData() {
|
||||
startTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({ searchParams });
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({ searchParams });
|
||||
}, 300);
|
||||
|
||||
function matchTypeLabel(type: RedirectRow["pathMatchType"]) {
|
||||
return {
|
||||
prefix: t("pathMatchPrefix"),
|
||||
exact: t("pathMatchExact"),
|
||||
regex: t("pathMatchRegex")
|
||||
}[type];
|
||||
}
|
||||
|
||||
function rewriteTypeLabel(type: RedirectRow["rewritePathType"]) {
|
||||
if (!type) return "";
|
||||
return {
|
||||
prefix: t("pathRewritePrefix"),
|
||||
exact: t("pathRewriteExact"),
|
||||
regex: t("pathRewriteRegex"),
|
||||
stripPrefix: t("pathRewriteStrip")
|
||||
}[type];
|
||||
}
|
||||
|
||||
async function toggleEnabled(row: RedirectRow, enabled: boolean) {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.redirectId === row.redirectId ? { ...r, enabled } : r
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/org/${orgId}/redirects/${row.redirectId}`, {
|
||||
enabled
|
||||
});
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.redirectId === row.redirectId
|
||||
? { ...r, enabled: row.enabled }
|
||||
: r
|
||||
)
|
||||
);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("redirectErrorUpdate"),
|
||||
description: formatAxiosError(e, t("redirectErrorUpdate"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRedirect(row: RedirectRow) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await api.delete(`/org/${orgId}/redirects/${row.redirectId}`);
|
||||
setRows((prev) =>
|
||||
prev.filter((r) => r.redirectId !== row.redirectId)
|
||||
);
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelected(null);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectDeleted")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("redirectErrorDelete"),
|
||||
description: formatAxiosError(e, t("redirectErrorDelete"))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<RedirectRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
friendlyName: t("redirectSource"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("redirectSource")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
// A domain-attached redirect may target a specific host
|
||||
// under the base domain, e.g. old.example.com.
|
||||
const domainHost = redirect.baseDomain
|
||||
? [redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")
|
||||
: null;
|
||||
const host = redirect.resourceFullDomain ?? domainHost;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{matchTypeLabel(redirect.pathMatchType)}
|
||||
</Badge>
|
||||
<code className="text-sm truncate">
|
||||
{host ?? ""}
|
||||
{redirect.matchPath}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "attachedTo",
|
||||
friendlyName: t("redirectAttachedTo"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("redirectAttachedTo")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
|
||||
if (redirect.resourceId && redirect.resourceNiceId) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/${redirect.resourceNiceId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{redirect.resourceName}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirect.baseDomain) {
|
||||
return (
|
||||
<span>
|
||||
{[redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "destinationDomain",
|
||||
friendlyName: t("redirectDestinationDomain"),
|
||||
header: () => (
|
||||
<span className="p-3">
|
||||
{t("redirectDestinationDomain")}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<code className="text-sm">
|
||||
{row.original.destinationDomain}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "rewritePath",
|
||||
accessorKey: "rewritePath",
|
||||
friendlyName: t("rewritePath"),
|
||||
header: () => <span className="p-3">{t("rewritePath")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
const hasRewrite =
|
||||
Boolean(redirect.rewritePath) ||
|
||||
redirect.rewritePathType === "stripPrefix";
|
||||
|
||||
if (!hasRewrite) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{rewriteTypeLabel(redirect.rewritePathType)}
|
||||
</Badge>
|
||||
<code className="text-sm truncate">
|
||||
{redirect.rewritePath ?? ""}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "permanent",
|
||||
friendlyName: t("redirectType"),
|
||||
header: () => <span className="p-3">{t("redirectType")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{row.original.permanent
|
||||
? t("redirectTypePermanent")
|
||||
: t("redirectTypeTemporary")}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleEnabled(row.original, checked)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
|
||||
>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[orgId, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
if (!val) {
|
||||
setSelected(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("redirectQuestionRemove")}</p>
|
||||
<p>{t("redirectMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("redirectDeleteConfirm")}
|
||||
onConfirm={async () => deleteRedirect(selected)}
|
||||
string={selected.name}
|
||||
title={t("redirectDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
addButtonText={t("redirectAdd")}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/redirects/create`)
|
||||
)
|
||||
}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
tableId="redirects-table"
|
||||
searchPlaceholder={t("redirectsSearch")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
attachedTo: false,
|
||||
rewritePath: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+16
-68
@@ -1,46 +1,23 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export function SettingsContainer({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-6", className)}>{children}</div>;
|
||||
export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({
|
||||
children,
|
||||
className
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({
|
||||
@@ -100,7 +77,7 @@ export function SettingsFormCell({
|
||||
"min-w-0",
|
||||
span === "quarter" && "md:col-span-1",
|
||||
span === "half" && "md:col-span-2",
|
||||
span === "full" && "col-span-full",
|
||||
span === "full" && "md:col-span-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -110,36 +87,23 @@ export function SettingsFormCell({
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({
|
||||
children,
|
||||
className
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(
|
||||
"text-1xl font-semibold tracking-tight flex items-center gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionDescription({
|
||||
children,
|
||||
className
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p className={cn("text-muted-foreground text-sm", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionHeader({
|
||||
@@ -177,15 +141,11 @@ export function SettingsSubsectionDescription({
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({
|
||||
children,
|
||||
className
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
|
||||
);
|
||||
return <div className="space-y-5 flex-grow">{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
@@ -209,22 +169,10 @@ export function SettingsSectionFooter({
|
||||
|
||||
export function SettingsSectionGrid({
|
||||
children,
|
||||
cols = 4,
|
||||
className
|
||||
cols
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols?: number;
|
||||
className?: string;
|
||||
cols: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, minmax(0, 1fr))`
|
||||
}}
|
||||
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@server/*": [
|
||||
|
||||
Reference in New Issue
Block a user