mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-21 12:09:04 +02:00
Merge pull request #3788 from fosrl/feat/redirect-tables
feat: Redirects
This commit is contained in:
+72
-2
@@ -295,7 +295,8 @@
|
||||
"resourceTypeDescription": "This controls the resource protocol and can’t be changed later.",
|
||||
"resourceTypeSearch": "Search resource types...",
|
||||
"resourceTypeNotFound": "No resource type found",
|
||||
"resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name",
|
||||
"resourceTypeHttp": "HTTP(S)",
|
||||
"resourceTypeHttpDescription": "Proxy web traffic over HTTP or HTTPS using a domain name",
|
||||
"resourceTypeInferenceDescription": "Route AI requests through attached providers",
|
||||
"resourceTypeSshDescription": "Access an SSH server from the browser",
|
||||
"resourceTypeRdpDescription": "Access a remote desktop from the browser",
|
||||
@@ -1523,6 +1524,11 @@
|
||||
"actionGetVirtualApiKey": "Get Virtual API Key",
|
||||
"actionListVirtualApiKeys": "List Virtual API Keys",
|
||||
"actionUpdateVirtualApiKey": "Update Virtual API Key",
|
||||
"actionCreateRedirect": "Create Redirect",
|
||||
"actionDeleteRedirect": "Delete Redirect",
|
||||
"actionGetRedirect": "Get Redirect",
|
||||
"actionListRedirects": "List Redirects",
|
||||
"actionUpdateRedirect": "Update Redirect",
|
||||
"actionApplyBlueprint": "Apply Blueprint",
|
||||
"actionListBlueprints": "List Blueprints",
|
||||
"actionGetBlueprint": "Get Blueprint",
|
||||
@@ -2150,6 +2156,7 @@
|
||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarOrgs": "Organizations",
|
||||
"sidebarRedirects": "Redirects",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
"sidebarAllUsers": "Users",
|
||||
@@ -2456,6 +2463,7 @@
|
||||
"domains": "Domains",
|
||||
"domainsDescription": "Create and manage domains available in the organization",
|
||||
"domainsSearch": "Search domains...",
|
||||
"domainsNotFound": "No domains found",
|
||||
"domainAdd": "Add Domain",
|
||||
"domainAddDescription": "Register a new domain with to the organization",
|
||||
"domainCreate": "Create Domain",
|
||||
@@ -2598,6 +2606,8 @@
|
||||
"billingModifyLicenses": "Modify License Subscription",
|
||||
"domainNotFound": "Domain Not Found",
|
||||
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
|
||||
"redirectDomainNotFoundDescription": "This redirect is disabled because the domain no longer exists our system. Please set a new domain for this redirect or its attached resource domain.",
|
||||
"redirectDomainSelectedDomainNotFound": "This redirect is disabled because its domain no longer exists. Select a different domain, or attach the redirect to a resource that has a valid domain.",
|
||||
"failed": "Failed",
|
||||
"createNewOrgDescription": "Create a new organization",
|
||||
"organization": "Organization",
|
||||
@@ -3565,7 +3575,7 @@
|
||||
"pathMatchExactHelp": "Example: /api matches only /api",
|
||||
"pathMatchRegexHelp": "Example: ^/api/.* matches /api/anything",
|
||||
"pathRewriteModalTitle": "Configure Path Rewriting",
|
||||
"pathRewriteModalDescription": "Transform the matched path before forwarding to the target.",
|
||||
"pathRewriteModalDescription": "Transform the matched path before forwarding to the destination.",
|
||||
"pathRewriteType": "Rewrite Type",
|
||||
"pathRewritePrefixOption": "Prefix - Replace prefix",
|
||||
"pathRewriteExactOption": "Exact - Replace entire path",
|
||||
@@ -3582,6 +3592,7 @@
|
||||
"pathRewriteExact": "Exact",
|
||||
"pathRewriteRegex": "Regex",
|
||||
"pathRewriteStrip": "Strip",
|
||||
"pathRewriteStripPrefix": "Strip Prefix",
|
||||
"pathRewriteStripLabel": "strip",
|
||||
"sidebarEnableEnterpriseLicense": "Enable Enterprise License",
|
||||
"cannotbeUndone": "This can not be undone.",
|
||||
@@ -3611,6 +3622,7 @@
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"validVirtualAPIKey": "Valid Virtual API Key",
|
||||
"allowedRedirect": "Allowed Redirect",
|
||||
"view": "View",
|
||||
"configManaged": "Config Managed",
|
||||
"connectedClient": "Connected Client",
|
||||
@@ -4481,6 +4493,64 @@
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||
"check": "Check",
|
||||
"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",
|
||||
"redirectDestination": "Destination",
|
||||
"redirectAttachedTo": "Attached To",
|
||||
"redirectFilterAnyDomain": "All domains",
|
||||
"redirectFilterAnyResource": "All resources",
|
||||
"redirectFilterDomainFallback": "Domain {id}",
|
||||
"redirectFilterResourceFallback": "Resource {id}",
|
||||
"redirectType": "Type",
|
||||
"redirectTypePermanent": "Permanent (308)",
|
||||
"redirectTypeTemporary": "Temporary (307)",
|
||||
"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.",
|
||||
"redirectDestinationHost": "Destination",
|
||||
"redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com",
|
||||
"redirectDestinationHostRequired": "Enter a destination",
|
||||
"redirectSameDomainAsSource": "Same Domain as Source",
|
||||
"redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path.",
|
||||
"redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com",
|
||||
"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",
|
||||
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
|
||||
"redirectPriorityInvalid": "Enter a whole number between 1 and 1000",
|
||||
"redirectPriorityDescription": "Higher priority routes are evaluated first.",
|
||||
"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 Rule",
|
||||
"selectedRedirectDomain": "Selected Domain",
|
||||
"selectedRedirectResource": "Selected Resource",
|
||||
"redirectResourceNoDomain": "This resource has no domain",
|
||||
"redirectSourceSectionDescription": "Choose the domain or resource, and which incoming paths, this redirect matches.",
|
||||
"redirectDestinationSectionDescription": "Where matching requests are sent, and how the path changes",
|
||||
"redirectSettingsDescription": "Set which paths to match and where to send them.",
|
||||
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it.",
|
||||
"redirectAttachDomain": "Domain",
|
||||
"redirectAttachDomainDescription": "Apply this redirect to a whole domain.",
|
||||
"redirectAttachResource": "Resource",
|
||||
"redirectAttachResourceDescription": "Apply this redirect to a single 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.",
|
||||
"redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.",
|
||||
"redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting.",
|
||||
"rulesErrorInvalidMethod": "Invalid HTTP method",
|
||||
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
|
||||
"rulesSelectMethods": "Select methods"
|
||||
|
||||
@@ -211,7 +211,12 @@ export enum ActionsEnum {
|
||||
deleteVirtualApiKey = "deleteVirtualApiKey",
|
||||
getVirtualApiKey = "getVirtualApiKey",
|
||||
listVirtualApiKeys = "listVirtualApiKeys",
|
||||
updateVirtualApiKey = "updateVirtualApiKey"
|
||||
updateVirtualApiKey = "updateVirtualApiKey",
|
||||
createRedirect = "createRedirect",
|
||||
deleteRedirect = "deleteRedirect",
|
||||
getRedirect = "getRedirect",
|
||||
listRedirects = "listRedirects",
|
||||
updateRedirect = "updateRedirect"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
aiProviders,
|
||||
clients,
|
||||
db,
|
||||
redirects,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
siteResources
|
||||
@@ -140,6 +141,30 @@ export async function getUniqueProviderName(orgId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueRedirectName(orgId: string): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
|
||||
const redirectCount = await db
|
||||
.select({
|
||||
niceId: redirects.niceId,
|
||||
orgId: redirects.orgId
|
||||
})
|
||||
.from(redirects)
|
||||
.where(and(eq(redirects.niceId, name), eq(redirects.orgId, orgId)));
|
||||
|
||||
if (redirectCount.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourcePolicyName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
|
||||
@@ -234,6 +234,41 @@ 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"),
|
||||
destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port]
|
||||
pathMatchType: varchar("pathMatchType")
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
.notNull()
|
||||
.default("regex"), // exact, prefix, regex
|
||||
matchPath: varchar("matchPath"),
|
||||
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
|
||||
priority: integer("priority").default(100),
|
||||
permanent: boolean("permanent").notNull().default(false),
|
||||
// Only consulted for domain-attached redirects; resource-attached ones
|
||||
// inherit the resource's ssl setting.
|
||||
ssl: boolean("ssl").notNull().default(true),
|
||||
enabled: boolean("enabled").notNull().default(true)
|
||||
});
|
||||
|
||||
export const resourceAiProviders = pgTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
@@ -2118,6 +2153,7 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
|
||||
export type Redirect = InferSelectModel<typeof redirects>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
|
||||
@@ -35,10 +35,12 @@ import {
|
||||
resourcePolicyHeaderAuth,
|
||||
ResourcePolicyHeaderAuth,
|
||||
resourceWhitelist,
|
||||
resourcePolicyWhiteList
|
||||
resourcePolicyWhiteList,
|
||||
redirects,
|
||||
domains
|
||||
} from "@server/db";
|
||||
import { alias } from "@server/db";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, not, or, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export type ResourceWithAuth = {
|
||||
@@ -53,11 +55,80 @@ export type ResourceWithAuth = {
|
||||
org: Org;
|
||||
};
|
||||
|
||||
export type RedirectByHost = {
|
||||
redirectId: number;
|
||||
orgId: string;
|
||||
matchPath: string | null;
|
||||
pathMatchType: string;
|
||||
destinationHost: string;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: string | null;
|
||||
permanent: boolean;
|
||||
priority: number | null;
|
||||
};
|
||||
|
||||
export type UserSessionWithUser = {
|
||||
session: any;
|
||||
user: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enabled redirects listening on the given host, highest priority first.
|
||||
* A redirect listens on its resource's fullDomain when attached to one,
|
||||
* otherwise on subdomain.baseDomain (or the bare baseDomain) of its domain.
|
||||
* Mirrors the host resolution in getTraefikConfig so badger agrees with
|
||||
* what Traefik routed.
|
||||
*/
|
||||
export async function getRedirectsByHost(
|
||||
host: string
|
||||
): Promise<RedirectByHost[]> {
|
||||
// A literal "*." leading label matches any single subdomain, like
|
||||
// wildcard resources do.
|
||||
const parts = host.split(".");
|
||||
const candidates = [host];
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
candidates.push(`*.${parts.slice(i).join(".")}`);
|
||||
}
|
||||
|
||||
const redirectHost = sql<string>`case
|
||||
when ${redirects.resourceId} is not null then ${resources.fullDomain}
|
||||
when ${redirects.subdomain} is null then ${domains.baseDomain}
|
||||
else ${redirects.subdomain} || '.' || ${domains.baseDomain}
|
||||
end`;
|
||||
|
||||
return db
|
||||
.select({
|
||||
redirectId: redirects.redirectId,
|
||||
orgId: redirects.orgId,
|
||||
matchPath: redirects.matchPath,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
destinationHost: redirects.destinationHost,
|
||||
rewritePath: redirects.rewritePath,
|
||||
rewritePathType: redirects.rewritePathType,
|
||||
permanent: redirects.permanent,
|
||||
priority: redirects.priority
|
||||
})
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.enabled, true),
|
||||
// Traefik drops resource-attached redirects along with a
|
||||
// disabled resource; do the same here.
|
||||
or(
|
||||
isNull(redirects.resourceId),
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
not(isNull(resources.domainId))
|
||||
)
|
||||
),
|
||||
inArray(redirectHost, candidates)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(redirects.priority));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource by domain with pincode and password information
|
||||
*/
|
||||
|
||||
@@ -252,6 +252,43 @@ 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"),
|
||||
destinationHost: text("destinationHost").notNull(), // scheme://host[:port]
|
||||
pathMatchType: text("pathMatchType")
|
||||
.$type<"exact" | "prefix" | "regex">()
|
||||
.notNull()
|
||||
.default("regex"), // exact, prefix, regex
|
||||
matchPath: text("matchPath"),
|
||||
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
|
||||
priority: integer("priority").default(100),
|
||||
permanent: integer("permanent", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
// Only consulted for domain-attached redirects; resource-attached ones
|
||||
// inherit the resource's ssl setting.
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
|
||||
});
|
||||
|
||||
export const resourceAiProviders = sqliteTable(
|
||||
"resourceAiProviders",
|
||||
{
|
||||
@@ -2164,6 +2201,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
|
||||
>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type Redirect = InferSelectModel<typeof redirects>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RemoteExitNode,
|
||||
Session,
|
||||
SiteResource,
|
||||
Redirect,
|
||||
User,
|
||||
UserOrg,
|
||||
VirtualApiKey
|
||||
@@ -96,6 +97,7 @@ declare global {
|
||||
aiModel?: AiModel;
|
||||
aiBudget?: AiBudget;
|
||||
virtualApiKey?: VirtualApiKey;
|
||||
redirect?: Redirect;
|
||||
orgPolicyAllowed?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,58 @@
|
||||
import {
|
||||
db,
|
||||
targetHealthCheck,
|
||||
domains,
|
||||
aiProviders,
|
||||
resourceAiProviders,
|
||||
siteResources,
|
||||
exitNodes,
|
||||
redirects,
|
||||
resources,
|
||||
siteNetworks,
|
||||
exitNodes
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
} from "@server/db";
|
||||
import regionalCache from "@server/lib/cache";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
or,
|
||||
isNull,
|
||||
ne,
|
||||
isNotNull,
|
||||
desc,
|
||||
isNull,
|
||||
not,
|
||||
or,
|
||||
sql
|
||||
} from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { resources, sites, targets } from "@server/db";
|
||||
import { applyPathRewriteMiddleware } from "./middleware";
|
||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||
import regionalCache from "@server/lib/cache";
|
||||
import { TargetWithSite } from "./types";
|
||||
import {
|
||||
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
|
||||
buildAiGatewayClientIpMiddleware,
|
||||
buildAiGatewayHostHeaderMiddleware,
|
||||
buildAiGatewayRouterAndService,
|
||||
buildAiGatewayTrustMiddlewares,
|
||||
getAiGatewayHost
|
||||
} from "./aiGatewayMiddlewares";
|
||||
import {
|
||||
buildBrowserGatewayConfig,
|
||||
buildBrowserGatewayResourcesMap
|
||||
} from "./browserGateway";
|
||||
import { buildWildcardTls } from "./certResolver";
|
||||
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
|
||||
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
buildStickySessionCookie,
|
||||
buildTcpUdpLoadBalancerServers,
|
||||
buildStickySessionIp
|
||||
buildStickySessionIp,
|
||||
buildTcpUdpLoadBalancerServers
|
||||
} from "./loadBalancer";
|
||||
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
|
||||
import {
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
|
||||
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
|
||||
getAiGatewayHost,
|
||||
buildAiGatewayTrustMiddlewares,
|
||||
buildAiGatewayClientIpMiddleware,
|
||||
buildAiGatewayHostHeaderMiddleware,
|
||||
buildAiGatewayRouterAndService
|
||||
} from "./aiGatewayMiddlewares";
|
||||
import {
|
||||
buildBrowserGatewayResourcesMap,
|
||||
buildBrowserGatewayConfig
|
||||
} from "./browserGateway";
|
||||
import { applyPathRewriteMiddleware } from "./middleware";
|
||||
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
|
||||
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
|
||||
import { TargetWithSite } from "./types";
|
||||
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
@@ -131,6 +133,7 @@ export async function getTraefikConfig(
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
|
||||
// Domain cert resolver fields
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert
|
||||
@@ -331,12 +334,93 @@ export async function getTraefikConfig(
|
||||
)
|
||||
);
|
||||
|
||||
// Redirects have no targets/sites, so like inference resources they are
|
||||
// queried separately and emitted on every exit node. A redirect listens
|
||||
// either on a resource's fullDomain or on subdomain.baseDomain of a
|
||||
// domain; the domain join resolves to whichever one applies.
|
||||
const redirectRows = await db
|
||||
.select({
|
||||
name: redirects.name,
|
||||
enabled: redirects.enabled,
|
||||
redirectId: redirects.redirectId,
|
||||
subdomain: redirects.subdomain,
|
||||
matchPath: redirects.matchPath,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
priority: redirects.priority,
|
||||
ssl: redirects.ssl,
|
||||
// Resource (when attached to one)
|
||||
resourceId: resources.resourceId,
|
||||
resourceFullDomain: resources.fullDomain,
|
||||
resourceSubdomain: resources.subdomain,
|
||||
resourceSsl: resources.ssl,
|
||||
resourceWildcard: resources.wildcard,
|
||||
// Domain (the redirect's own, or the resource's)
|
||||
baseDomain: domains.baseDomain,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert
|
||||
})
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(
|
||||
domains,
|
||||
eq(
|
||||
domains.domainId,
|
||||
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.enabled, true),
|
||||
or(
|
||||
isNull(redirects.resourceId),
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
not(isNull(resources.domainId))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
|
||||
|
||||
const redirectRoutes: RedirectRouteRow[] = [];
|
||||
for (const row of redirectRows) {
|
||||
const attachedToResource = row.resourceId !== null;
|
||||
const fullDomain = attachedToResource
|
||||
? row.resourceFullDomain
|
||||
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
|
||||
if (!fullDomain) {
|
||||
logger.debug(
|
||||
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
redirectRoutes.push({
|
||||
enabled: row.enabled,
|
||||
name: sanitize(row.name) || "",
|
||||
redirectId: row.redirectId,
|
||||
fullDomain,
|
||||
hasSubdomain: attachedToResource
|
||||
? !!row.resourceSubdomain
|
||||
: !!row.subdomain,
|
||||
wildcard: row.resourceWildcard,
|
||||
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
|
||||
attachedTo: attachedToResource ? "resource" : "domain",
|
||||
matchPath: row.matchPath,
|
||||
pathMatchType: row.pathMatchType,
|
||||
priority: row.priority,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert
|
||||
});
|
||||
}
|
||||
|
||||
// make sure we have at least one resource
|
||||
if (
|
||||
resourcesMap.size === 0 &&
|
||||
inferenceResources.length === 0 &&
|
||||
browserGatewayResourcesMap.size === 0 &&
|
||||
siteResourcesWithFullDomain.length === 0
|
||||
siteResourcesWithFullDomain.length === 0 &&
|
||||
redirectRoutes.length === 0
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
@@ -527,6 +611,21 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
buildRedirectConfig({
|
||||
config_output,
|
||||
redirects: redirectRoutes,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (redirect) =>
|
||||
buildWildcardTls({
|
||||
fullDomain: redirect.fullDomain,
|
||||
hasSubdomain: redirect.hasSubdomain,
|
||||
domainCertResolver: redirect.domainCertResolver,
|
||||
preferWildcardCert:
|
||||
redirect.preferWildcardCert || redirect.wildcard
|
||||
})
|
||||
});
|
||||
|
||||
if (browserGatewayUiUrl) {
|
||||
buildBrowserGatewayConfig({
|
||||
config_output,
|
||||
|
||||
@@ -11,21 +11,12 @@ function makeTarget(ip: string, siteType: string): TargetWithSite {
|
||||
return {
|
||||
targetId: 1,
|
||||
resourceId: 1,
|
||||
providerId: null,
|
||||
siteId: 1,
|
||||
ip,
|
||||
port: 8080,
|
||||
internalPort: 9000,
|
||||
method: "http",
|
||||
enabled: true,
|
||||
health: "healthy",
|
||||
path: null,
|
||||
pathMatchType: null,
|
||||
rewritePath: null,
|
||||
rewritePathType: null,
|
||||
priority: 100,
|
||||
mode: "http",
|
||||
authToken: null,
|
||||
site: {
|
||||
siteId: 1,
|
||||
type: siteType,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${formatEndpoint(target.ip, target.port)}`
|
||||
url: `${target.method}://${formatEndpoint(target.ip!, target.port!)}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip = target.site.subnet!.split("/")[0];
|
||||
@@ -124,7 +124,7 @@ export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) {
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
address: formatEndpoint(target.ip, target.port)
|
||||
address: formatEndpoint(target.ip!, target.port!)
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip = target.site.subnet!.split("/")[0];
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { assertEquals } from "../../../test/assert";
|
||||
import { rewriteRequestPath } from "./middleware";
|
||||
|
||||
function runTests() {
|
||||
console.log("Running rewriteRequestPath tests...");
|
||||
|
||||
// no rewrite configured
|
||||
assertEquals(
|
||||
rewriteRequestPath("/a/b", "/a", "prefix", null, null),
|
||||
"/a/b",
|
||||
"no rewrite type"
|
||||
);
|
||||
|
||||
// exact rewrite
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "exact", "/new", "exact"),
|
||||
"/new",
|
||||
"exact -> exact"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/x", "/old", "exact", "/new", "exact"),
|
||||
"/old/x",
|
||||
"exact rewrite only on exact match"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "exact", "new", "exact"),
|
||||
"/new",
|
||||
"leading slash added to rewrite"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/anything", null, null, "/new", "exact"),
|
||||
"/new",
|
||||
"no match path + exact rewrite replaces path"
|
||||
);
|
||||
|
||||
// prefix rewrite
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/a/b", "/old", "prefix", "/new", "prefix"),
|
||||
"/new/a/b",
|
||||
"prefix -> prefix keeps rest"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "prefix", "/new", "prefix"),
|
||||
"/new",
|
||||
"prefix -> prefix bare"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "exact", "/new", "prefix"),
|
||||
"/new",
|
||||
"exact -> prefix"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath(
|
||||
"/api/v1/users",
|
||||
"^/api/v1/(.*)",
|
||||
"regex",
|
||||
"/v2/$1",
|
||||
"prefix"
|
||||
),
|
||||
"/v2/users",
|
||||
"regex -> prefix uses capture"
|
||||
);
|
||||
|
||||
// regex rewrite
|
||||
assertEquals(
|
||||
rewriteRequestPath(
|
||||
"/blog/2020/post",
|
||||
"^/blog/(\\d+)/(.*)$",
|
||||
"regex",
|
||||
"/archive/$2-$1",
|
||||
"regex"
|
||||
),
|
||||
"/archive/post-2020",
|
||||
"regex -> regex"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/x", "/old", "prefix", "/new$1", "regex"),
|
||||
"/new/x",
|
||||
"prefix -> regex has (.*) capture"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "exact", "/new", "regex"),
|
||||
"/new",
|
||||
"exact -> regex"
|
||||
);
|
||||
|
||||
// stripPrefix
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/a", "/old", "prefix", null, "stripPrefix"),
|
||||
"/a",
|
||||
"stripPrefix"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "prefix", null, "stripPrefix"),
|
||||
"/",
|
||||
"stripPrefix to root"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/a", "/old", "prefix", "/new", "stripPrefix"),
|
||||
"/new/a",
|
||||
"stripPrefix + addPrefix"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old", "/old", "exact", null, "stripPrefix"),
|
||||
"/",
|
||||
"stripPrefix exact"
|
||||
);
|
||||
// Same result the replacePathRegex middleware produces for this config
|
||||
// (regex `^/old` -> `/`), quirky double slash included.
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/a", "^/old", "regex", null, "stripPrefix"),
|
||||
"//a",
|
||||
"stripPrefix regex mirrors Traefik"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/old/a", "^/old/", "regex", null, "stripPrefix"),
|
||||
"/a",
|
||||
"stripPrefix regex with trailing slash"
|
||||
);
|
||||
assertEquals(
|
||||
rewriteRequestPath("/a/b", null, null, null, "stripPrefix"),
|
||||
"/a/b",
|
||||
"stripPrefix without match path is a no-op"
|
||||
);
|
||||
|
||||
console.log("All rewriteRequestPath tests passed!");
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -194,6 +194,78 @@ export default function createPathRewriteMiddleware(
|
||||
return { middlewares };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a path rewrite to a request path in-process, producing the same
|
||||
* result the replacePathRegex / stripPrefix middlewares built above would.
|
||||
* Used where Pangolin issues the redirect itself (badger) instead of
|
||||
* handing it to Traefik.
|
||||
*/
|
||||
export function rewriteRequestPath(
|
||||
requestPath: string,
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): string {
|
||||
if (!rewritePathType) {
|
||||
return requestPath;
|
||||
}
|
||||
|
||||
let target = rewritePath ?? "";
|
||||
if (
|
||||
rewritePathType !== "regex" &&
|
||||
target !== "" &&
|
||||
!target.startsWith("/")
|
||||
) {
|
||||
target = `/${target}`;
|
||||
}
|
||||
|
||||
// Nothing was matched against, so there is nothing to strip or replace;
|
||||
// an exact rewrite is the only one that still means something.
|
||||
if (!path || !pathMatchType) {
|
||||
return rewritePathType === "exact" ? target || "/" : requestPath;
|
||||
}
|
||||
|
||||
let matched = path;
|
||||
if (pathMatchType !== "regex" && !matched.startsWith("/")) {
|
||||
matched = `/${matched}`;
|
||||
}
|
||||
|
||||
const matchRegex =
|
||||
pathMatchType === "regex"
|
||||
? matched
|
||||
: pathMatchType === "prefix"
|
||||
? `^${escapeRegex(matched)}(.*)`
|
||||
: `^${escapeRegex(matched)}$`;
|
||||
|
||||
switch (rewritePathType) {
|
||||
case "exact":
|
||||
return requestPath.replace(
|
||||
new RegExp(`^${escapeRegex(matched)}$`),
|
||||
target
|
||||
);
|
||||
case "prefix":
|
||||
return requestPath.replace(
|
||||
new RegExp(matchRegex),
|
||||
pathMatchType === "prefix" ? `${target}$1` : target
|
||||
);
|
||||
case "regex":
|
||||
return requestPath.replace(new RegExp(matchRegex), target);
|
||||
case "stripPrefix": {
|
||||
if (pathMatchType === "prefix") {
|
||||
const stripped = requestPath.startsWith(matched)
|
||||
? requestPath.slice(matched.length)
|
||||
: requestPath;
|
||||
const prefix = target && target !== "/" ? target : "";
|
||||
return `${prefix}${stripped}` || "/";
|
||||
}
|
||||
return requestPath.replace(new RegExp(matchRegex), target || "/");
|
||||
}
|
||||
default:
|
||||
return requestPath;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
buildHostRule,
|
||||
appendPathMatch,
|
||||
computeRedirectPriority
|
||||
} from "@server/lib/traefik/rule";
|
||||
|
||||
export type RedirectRouteRow = {
|
||||
redirectId: number;
|
||||
/** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */
|
||||
fullDomain: string;
|
||||
hasSubdomain: boolean;
|
||||
attachedTo: "resource" | "domain";
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
wildcard: boolean | null;
|
||||
ssl: boolean;
|
||||
matchPath: string | null;
|
||||
pathMatchType: string;
|
||||
priority: number | null;
|
||||
domainCertResolver?: string | null;
|
||||
preferWildcardCert?: boolean | null;
|
||||
};
|
||||
|
||||
// Traefik requires a service on every router, but a redirect router's
|
||||
// middleware chain always terminates the request with a 30x, so the service
|
||||
// is never reached. noop@internal answers 418 if it ever is - treat that as
|
||||
// a bug in the middleware chain, not something to route around.
|
||||
const NOOP_SERVICE = "noop@internal";
|
||||
|
||||
export function buildRedirectConfig(params: {
|
||||
config_output: any;
|
||||
redirects: RedirectRouteRow[];
|
||||
badgerMiddlewareName: string;
|
||||
redirectHttpsMiddlewareName: string;
|
||||
resolveTls: (row: RedirectRouteRow) => any | null;
|
||||
}): void {
|
||||
const {
|
||||
config_output,
|
||||
redirects,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls
|
||||
} = params;
|
||||
|
||||
if (redirects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const httpEntrypoint = config.getRawConfig().traefik.http_entrypoint;
|
||||
const httpsEntrypoint = config.getRawConfig().traefik.https_entrypoint;
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares];
|
||||
|
||||
for (const redirect of redirects) {
|
||||
const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`;
|
||||
|
||||
if (!redirect.enabled) {
|
||||
logger.debug(
|
||||
`Redirect ${redirect.name} is disabled, skipping Traefik config`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let tls: any = {};
|
||||
if (redirect.ssl) {
|
||||
tls = resolveTls(redirect);
|
||||
if (tls === null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
|
||||
if (redirect.matchPath && redirect.pathMatchType === "regex") {
|
||||
try {
|
||||
new RegExp(redirect.matchPath);
|
||||
} catch {
|
||||
logger.debug(
|
||||
`Invalid regex pattern in redirect ${redirect.redirectId} match path: ${redirect.matchPath}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const rule = appendPathMatch(
|
||||
buildHostRule(redirect.fullDomain, redirect.wildcard),
|
||||
redirect.matchPath,
|
||||
redirect.pathMatchType
|
||||
);
|
||||
|
||||
const priority = computeRedirectPriority(
|
||||
redirect.priority,
|
||||
redirect.matchPath,
|
||||
redirect.pathMatchType
|
||||
);
|
||||
|
||||
// if resource is already attached to resource, we don't need to add the https redirect
|
||||
// as it is already added in the resource traefik config
|
||||
if (redirect.attachedTo !== "resource" && redirect.ssl) {
|
||||
config_output.http.routers[`${routerName}-redirect`] = {
|
||||
entryPoints: [httpEntrypoint],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: NOOP_SERVICE,
|
||||
rule,
|
||||
priority
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.routers[routerName] = {
|
||||
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
|
||||
middlewares: routerMiddlewares,
|
||||
service: NOOP_SERVICE,
|
||||
rule,
|
||||
priority,
|
||||
...(redirect.ssl ? { tls } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { assertEquals } from "../../../test/assert";
|
||||
import {
|
||||
computeRedirectPriority,
|
||||
computeRoutePriority,
|
||||
matchesPath
|
||||
} from "./rule";
|
||||
|
||||
function runTests() {
|
||||
console.log("Running matchesPath tests...");
|
||||
|
||||
// No path config matches everything
|
||||
assertEquals(matchesPath("/anything", null, null), true, "null path");
|
||||
assertEquals(matchesPath("/anything", "/a", null), true, "null type");
|
||||
assertEquals(
|
||||
matchesPath("/anything", null, "prefix"),
|
||||
true,
|
||||
"null path w/ type"
|
||||
);
|
||||
|
||||
// exact
|
||||
assertEquals(matchesPath("/api", "/api", "exact"), true, "exact match");
|
||||
assertEquals(
|
||||
matchesPath("/api/", "/api", "exact"),
|
||||
false,
|
||||
"exact trailing slash"
|
||||
);
|
||||
assertEquals(matchesPath("/api/x", "/api", "exact"), false, "exact child");
|
||||
assertEquals(
|
||||
matchesPath("/api", "api", "exact"),
|
||||
true,
|
||||
"exact leading slash added"
|
||||
);
|
||||
|
||||
// prefix (segment-aware, like Traefik v3 PathPrefix)
|
||||
assertEquals(
|
||||
matchesPath("/products", "/products", "prefix"),
|
||||
true,
|
||||
"prefix itself"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/products/", "/products", "prefix"),
|
||||
true,
|
||||
"prefix slash"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/products/shoes", "/products", "prefix"),
|
||||
true,
|
||||
"prefix child"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/productsforsale", "/products", "prefix"),
|
||||
false,
|
||||
"prefix partial segment"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/products/shoes", "/products/", "prefix"),
|
||||
true,
|
||||
"prefix with trailing slash"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/products", "/products/", "prefix"),
|
||||
false,
|
||||
"trailing-slash prefix vs bare"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/other", "/products", "prefix"),
|
||||
false,
|
||||
"prefix miss"
|
||||
);
|
||||
|
||||
// regex (unanchored, like PathRegexp)
|
||||
assertEquals(
|
||||
matchesPath("/api/v1/x", "^/api/.*", "regex"),
|
||||
true,
|
||||
"regex anchored"
|
||||
);
|
||||
assertEquals(
|
||||
matchesPath("/x/api/v1", "/api/", "regex"),
|
||||
true,
|
||||
"regex unanchored"
|
||||
);
|
||||
assertEquals(matchesPath("/foo", "^/api", "regex"), false, "regex miss");
|
||||
assertEquals(
|
||||
matchesPath("/foo", "(", "regex"),
|
||||
false,
|
||||
"invalid regex never matches"
|
||||
);
|
||||
|
||||
console.log("All matchesPath tests passed!");
|
||||
|
||||
console.log("Running priority tests...");
|
||||
|
||||
// Resource routers: explicit override, else derived from path specificity
|
||||
assertEquals(computeRoutePriority(null, null, null), 100, "default");
|
||||
assertEquals(computeRoutePriority(100, null, null), 100, "100 is auto");
|
||||
assertEquals(computeRoutePriority(500, "/a", "exact"), 500, "explicit");
|
||||
assertEquals(computeRoutePriority(null, "/a", "exact"), 115, "exact");
|
||||
assertEquals(computeRoutePriority(null, "/a", "prefix"), 113, "prefix");
|
||||
assertEquals(computeRoutePriority(null, "/a", "regex"), 112, "regex");
|
||||
assertEquals(computeRoutePriority(null, "/", "prefix"), 1, "catch-all");
|
||||
|
||||
// Redirect routers always land above any resource router (max 1000)
|
||||
assertEquals(
|
||||
computeRedirectPriority(null, null, null),
|
||||
1100,
|
||||
"redirect default"
|
||||
);
|
||||
assertEquals(
|
||||
computeRedirectPriority(null, "/", "prefix"),
|
||||
1001,
|
||||
"redirect catch-all"
|
||||
);
|
||||
assertEquals(
|
||||
computeRedirectPriority(1, null, null),
|
||||
1001,
|
||||
"redirect lowest explicit"
|
||||
);
|
||||
assertEquals(
|
||||
computeRedirectPriority(1000, null, null),
|
||||
2000,
|
||||
"redirect highest explicit"
|
||||
);
|
||||
assertEquals(
|
||||
computeRedirectPriority(1, "/", "prefix") >
|
||||
computeRoutePriority(1000, "/a", "exact"),
|
||||
true,
|
||||
"weakest redirect beats strongest resource"
|
||||
);
|
||||
|
||||
console.log("All priority tests passed!");
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -36,6 +36,48 @@ export function appendPathMatch(
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side equivalent of the clause appendPathMatch emits, so badger can
|
||||
* tell whether a request would have matched a given path config. Mirrors
|
||||
* Traefik v3 semantics: Path is exact, PathPrefix is segment-aware
|
||||
* (`/products` matches `/products/shoes` but not `/productsforsale`), and
|
||||
* PathRegexp is an unanchored regex test.
|
||||
*/
|
||||
export function matchesPath(
|
||||
requestPath: string,
|
||||
path: string | null | undefined,
|
||||
pathMatchType: string | null | undefined
|
||||
): boolean {
|
||||
if (!path || !pathMatchType) return true;
|
||||
|
||||
if (pathMatchType === "regex") {
|
||||
try {
|
||||
return new RegExp(path).test(requestPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let p = path;
|
||||
if (!p.startsWith("/")) {
|
||||
p = `/${p}`;
|
||||
}
|
||||
|
||||
if (pathMatchType === "exact") {
|
||||
return requestPath === p;
|
||||
} else if (pathMatchType === "prefix") {
|
||||
if (!requestPath.startsWith(p)) {
|
||||
return false;
|
||||
}
|
||||
if (p.endsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
const rest = requestPath.slice(p.length);
|
||||
return rest === "" || rest.startsWith("/");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compute the router priority for a resource, favoring an explicit override
|
||||
// and otherwise deriving it from the path match specificity.
|
||||
export function computeRoutePriority(
|
||||
@@ -63,3 +105,25 @@ export function computeRoutePriority(
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// Redirects must always be evaluated before resource routers on the same
|
||||
// host. Target and redirect priorities are both capped at 1000, so lifting
|
||||
// every redirect by this offset puts them in a band (1001-2000) no resource
|
||||
// router can reach, while explicit priorities still order redirects among
|
||||
// themselves.
|
||||
export const REDIRECT_PRIORITY_OFFSET = 1000;
|
||||
|
||||
/**
|
||||
* Compute the router priority for a redirect: the same derivation as a
|
||||
* resource router, shifted into the redirect band.
|
||||
*/
|
||||
export function computeRedirectPriority(
|
||||
priority: number | null | undefined,
|
||||
path: string | null | undefined,
|
||||
pathMatchType: string | null | undefined
|
||||
): number {
|
||||
return (
|
||||
computeRoutePriority(priority, path, pathMatchType) +
|
||||
REDIRECT_PRIORITY_OFFSET
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Target } from "@server/db";
|
||||
import type { Domain, Resource, Target } from "@server/db";
|
||||
|
||||
// Extended target type with site information, shared between the OSS and
|
||||
// Target subset with site information, shared between the OSS and
|
||||
// private getTraefikConfig implementations.
|
||||
export type TargetWithSite = Target & {
|
||||
export type TargetWithSite = {
|
||||
resourceId: number;
|
||||
targetId: number;
|
||||
ip: string | null;
|
||||
@@ -19,3 +19,48 @@ export type TargetWithSite = Target & {
|
||||
online: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// A resource grouped with its targets for router/service generation. Every
|
||||
// target in a group shares the same path/rewrite config, so those columns
|
||||
// live on the resource rather than on each target.
|
||||
export type ResourceWithTargets = Pick<
|
||||
Resource,
|
||||
| "resourceId"
|
||||
| "fullDomain"
|
||||
| "ssl"
|
||||
| "proxyPort"
|
||||
| "subdomain"
|
||||
| "domainId"
|
||||
| "enabled"
|
||||
| "stickySession"
|
||||
| "loadBalancingStrategy"
|
||||
| "mtlsEnabled"
|
||||
| "tlsServerName"
|
||||
| "setHostHeader"
|
||||
| "enableProxy"
|
||||
| "requestHeaders"
|
||||
| "responseHeaders"
|
||||
| "proxyProtocol"
|
||||
| "wildcard"
|
||||
| "mode"
|
||||
| "maintenanceModeEnabled"
|
||||
| "maintenanceModeType"
|
||||
| "maintenanceTitle"
|
||||
| "maintenanceMessage"
|
||||
| "maintenanceEstimatedTime"
|
||||
> &
|
||||
Pick<
|
||||
Target,
|
||||
"path" | "pathMatchType" | "rewritePath" | "rewritePathType"
|
||||
> & {
|
||||
/** Sanitized resource name used in router/service names */
|
||||
name: string;
|
||||
/** Sanitized resourceId + path config, unique per router */
|
||||
key: string;
|
||||
priority: number;
|
||||
proxyProtocolVersion: number;
|
||||
// Left-joined from the resource's domain, so absent when there is none
|
||||
domainCertResolver: Domain["certResolver"] | null;
|
||||
preferWildcardCert: Domain["preferWildcardCert"] | null;
|
||||
targets: TargetWithSite[];
|
||||
};
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./verifyApiKeyAiBudgetAccess";
|
||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||
export * from "./verifyApiKeySiteProvisioningKeyAccess";
|
||||
export * from "./verifyVirtualApiKeyAccess";
|
||||
export * from "./verifyApiKeyRedirectAccess";
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Redirect, apiKeyOrg, db, redirects } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getFirstString } from "@server/lib/requestParams";
|
||||
|
||||
export async function verifyApiKeyRedirectAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const apiKey = req.apiKey;
|
||||
const redirectIdRaw = getFirstString(req.params.redirectId);
|
||||
const niceId = getFirstString(req.params.niceId);
|
||||
const orgIdParam = getFirstString(req.params.orgId);
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
if (!orgIdParam) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
let redirect: Redirect | undefined;
|
||||
|
||||
if (niceId) {
|
||||
const [redirectRes] = await db
|
||||
.select()
|
||||
.from(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.niceId, niceId),
|
||||
eq(redirects.orgId, orgIdParam)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
redirect = redirectRes;
|
||||
} else {
|
||||
const redirectId = Number.parseInt(redirectIdRaw ?? "", 10);
|
||||
|
||||
if (Number.isNaN(redirectId)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid redirect ID")
|
||||
);
|
||||
}
|
||||
|
||||
const [redirectRes] = await db
|
||||
.select()
|
||||
.from(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.redirectId, redirectId),
|
||||
eq(redirects.orgId, orgIdParam)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
redirect = redirectRes;
|
||||
}
|
||||
|
||||
if (!redirect) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Redirect with ID ${redirectIdRaw || niceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
req.redirect = redirect;
|
||||
return next();
|
||||
}
|
||||
|
||||
const orgId = redirect.orgId;
|
||||
|
||||
if (!req.apiKeyOrg || req.apiKeyOrg.orgId !== orgId) {
|
||||
const apiKeyOrgRes = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.redirect = redirect;
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying redirect access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export enum OpenAPITags {
|
||||
AiModel = "AI Model",
|
||||
AiBudget = "AI Budget",
|
||||
VirtualApiKey = "Virtual API Key",
|
||||
Redirect = "Redirect",
|
||||
Label = "Label"
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { regionalCache } from "#private/lib/cache";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import license from "#private/license/license";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
certificates,
|
||||
db,
|
||||
@@ -19,74 +23,72 @@ import {
|
||||
exitNodes,
|
||||
loginPage,
|
||||
resourceMtlsCertificates,
|
||||
SiteResource,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
or,
|
||||
isNull,
|
||||
ne,
|
||||
isNotNull,
|
||||
desc,
|
||||
sql
|
||||
} from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
orgs,
|
||||
redirects,
|
||||
resources,
|
||||
sites,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
} from "@server/db";
|
||||
import {
|
||||
sanitize,
|
||||
encodePath,
|
||||
validatePathRewriteConfig
|
||||
} from "@server/lib/traefik/utils";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware";
|
||||
import {
|
||||
CertificateResult,
|
||||
getValidCertificatesForDomains
|
||||
} from "@server/lib/certificates";
|
||||
import { build } from "@server/build";
|
||||
import license from "#private/license/license";
|
||||
import { regionalCache } from "#private/lib/cache";
|
||||
import { TargetWithSite } from "@server/lib/traefik/types";
|
||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
buildHostRule,
|
||||
appendPathMatch,
|
||||
computeRoutePriority
|
||||
} from "@server/lib/traefik/rule";
|
||||
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
|
||||
buildAiGatewayClientIpMiddleware,
|
||||
buildAiGatewayHostHeaderMiddleware,
|
||||
buildAiGatewayRouterAndService,
|
||||
buildAiGatewayTrustMiddlewares,
|
||||
getAiGatewayHost
|
||||
} from "@server/lib/traefik/aiGatewayMiddlewares";
|
||||
import {
|
||||
buildBrowserGatewayConfig,
|
||||
buildBrowserGatewayResourcesMap
|
||||
} from "@server/lib/traefik/browserGateway";
|
||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||
import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware";
|
||||
import {
|
||||
buildHttpLoadBalancerServers,
|
||||
buildLoadBalancingStrategy,
|
||||
buildStickySessionCookie,
|
||||
buildTcpUdpLoadBalancerServers,
|
||||
buildStickySessionIp
|
||||
buildStickySessionIp,
|
||||
buildTcpUdpLoadBalancerServers
|
||||
} from "@server/lib/traefik/loadBalancer";
|
||||
import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware";
|
||||
import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware";
|
||||
import {
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
|
||||
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
|
||||
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
|
||||
getAiGatewayHost,
|
||||
buildAiGatewayTrustMiddlewares,
|
||||
buildAiGatewayClientIpMiddleware,
|
||||
buildAiGatewayHostHeaderMiddleware,
|
||||
buildAiGatewayRouterAndService
|
||||
} from "@server/lib/traefik/aiGatewayMiddlewares";
|
||||
buildRedirectConfig,
|
||||
RedirectRouteRow
|
||||
} from "@server/lib/traefik/redirect";
|
||||
import {
|
||||
buildBrowserGatewayResourcesMap,
|
||||
buildBrowserGatewayConfig
|
||||
} from "@server/lib/traefik/browserGateway";
|
||||
appendPathMatch,
|
||||
buildHostRule,
|
||||
computeRoutePriority
|
||||
} from "@server/lib/traefik/rule";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
|
||||
import { ResourceWithTargets } from "@server/lib/traefik/types";
|
||||
import {
|
||||
encodePath,
|
||||
sanitize,
|
||||
validatePathRewriteConfig
|
||||
} from "@server/lib/traefik/utils";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
not,
|
||||
or,
|
||||
sql
|
||||
} from "drizzle-orm";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||
@@ -220,7 +222,7 @@ export async function getTraefikConfig(
|
||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
const resourcesMap = new Map<string, ResourceWithTargets>();
|
||||
|
||||
for (const row of resourcesWithTargetsAndSites) {
|
||||
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||
@@ -248,7 +250,7 @@ export async function getTraefikConfig(
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
const key = sanitize(mapKey);
|
||||
const key = sanitize(mapKey) ?? "";
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
@@ -306,7 +308,7 @@ export async function getTraefikConfig(
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourcesMap.get(mapKey)!.targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
@@ -433,6 +435,95 @@ export async function getTraefikConfig(
|
||||
)
|
||||
);
|
||||
|
||||
// Redirects have no targets/sites, so like inference resources they are
|
||||
// queried separately and emitted on every exit node. A redirect listens
|
||||
// either on a resource's fullDomain or on subdomain.baseDomain of a
|
||||
// domain; the domain join resolves to whichever one applies.
|
||||
const redirectRows = await db
|
||||
.select({
|
||||
name: redirects.name,
|
||||
enabled: redirects.enabled,
|
||||
redirectId: redirects.redirectId,
|
||||
subdomain: redirects.subdomain,
|
||||
matchPath: redirects.matchPath,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
priority: redirects.priority,
|
||||
ssl: redirects.ssl,
|
||||
// Resource (when attached to one)
|
||||
resourceId: resources.resourceId,
|
||||
resourceFullDomain: resources.fullDomain,
|
||||
resourceSubdomain: resources.subdomain,
|
||||
resourceSsl: resources.ssl,
|
||||
resourceWildcard: resources.wildcard,
|
||||
// Domain (the redirect's own, or the resource's)
|
||||
baseDomain: domains.baseDomain,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId
|
||||
})
|
||||
.from(redirects)
|
||||
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
|
||||
.leftJoin(
|
||||
domains,
|
||||
eq(
|
||||
domains.domainId,
|
||||
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, domains.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.enabled, true),
|
||||
or(
|
||||
isNull(redirects.resourceId),
|
||||
and(
|
||||
eq(resources.enabled, true),
|
||||
not(isNull(resources.domainId))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
|
||||
|
||||
const redirectRoutes: RedirectRouteRow[] = [];
|
||||
for (const row of redirectRows) {
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachedToResource = row.resourceId !== null;
|
||||
const fullDomain = attachedToResource
|
||||
? row.resourceFullDomain
|
||||
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
|
||||
if (!fullDomain) {
|
||||
logger.debug(
|
||||
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
redirectRoutes.push({
|
||||
enabled: row.enabled,
|
||||
name: sanitize(row.name) || "",
|
||||
redirectId: row.redirectId,
|
||||
fullDomain,
|
||||
hasSubdomain: attachedToResource
|
||||
? !!row.resourceSubdomain
|
||||
: !!row.subdomain,
|
||||
wildcard: row.resourceWildcard,
|
||||
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
|
||||
attachedTo: attachedToResource ? "resource" : "domain",
|
||||
matchPath: row.matchPath,
|
||||
pathMatchType: row.pathMatchType,
|
||||
priority: row.priority,
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert
|
||||
});
|
||||
}
|
||||
|
||||
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
|
||||
// license (self-hosted) or a saas build - otherwise fall back to
|
||||
// Traefik's own cert resolvers (buildWildcardTls) throughout.
|
||||
@@ -472,6 +563,12 @@ export async function getTraefikConfig(
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// Include redirect hosts
|
||||
for (const redirect of redirectRoutes) {
|
||||
if (redirect.ssl) {
|
||||
domains.add(redirect.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
@@ -506,7 +603,7 @@ export async function getTraefikConfig(
|
||||
|
||||
// get the key and the resource
|
||||
for (const [, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets as TargetWithSite[];
|
||||
const targets = resource.targets;
|
||||
const key = resource.key;
|
||||
|
||||
const routerName = `${key}-${resource.name}-router`;
|
||||
@@ -685,7 +782,7 @@ export async function getTraefikConfig(
|
||||
|
||||
if (!target.site.online) return false;
|
||||
|
||||
if (target.health == "unhealthy") return false;
|
||||
if (target.health === "unhealthy") return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -875,6 +972,34 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
buildRedirectConfig({
|
||||
config_output,
|
||||
redirects: redirectRoutes,
|
||||
badgerMiddlewareName,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (redirect) => {
|
||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
return buildWildcardTls({
|
||||
fullDomain: redirect.fullDomain,
|
||||
hasSubdomain: redirect.hasSubdomain,
|
||||
domainCertResolver: redirect.domainCertResolver,
|
||||
preferWildcardCert:
|
||||
redirect.preferWildcardCert || redirect.wildcard
|
||||
});
|
||||
}
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === redirect.fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for redirect domain: ${redirect.fullDomain}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
if (browserGatewayUiUrl) {
|
||||
buildBrowserGatewayConfig({
|
||||
config_output,
|
||||
|
||||
@@ -96,12 +96,20 @@ import semver from "semver";
|
||||
import { maxmindAsnLookup } from "@server/db/maxmindAsn";
|
||||
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
|
||||
import { sanitizeString } from "@server/lib/sanitize";
|
||||
import {
|
||||
getRedirectsByHost,
|
||||
type RedirectByHost
|
||||
} from "@server/db/queries/verifySessionQueries";
|
||||
|
||||
// Zod schemas for request validation
|
||||
const getResourceByDomainParamsSchema = z.strictObject({
|
||||
domain: z.string().min(1, "Domain is required")
|
||||
});
|
||||
|
||||
const getRedirectsByHostParamsSchema = z.strictObject({
|
||||
host: z.string().min(1, "Host is required")
|
||||
});
|
||||
|
||||
const getUserSessionParamsSchema = z.strictObject({
|
||||
userSessionId: z.string().min(1, "User session ID is required")
|
||||
});
|
||||
@@ -818,6 +826,44 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
hybridRouter.get(
|
||||
"/redirects/host/:host",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedParams = getRedirectsByHostParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { host } = parsedParams.data;
|
||||
const redirects = await getRedirectsByHost(host);
|
||||
|
||||
return response<RedirectByHost[]>(res, {
|
||||
data: redirects,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Redirects retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to get redirects by host"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getOrgLoginPageParamsSchema = z.strictObject({
|
||||
orgId: z.string().min(1)
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ Reasons:
|
||||
107 - Valid SSO
|
||||
108 - Connected Client
|
||||
109 - Valid Virtual API Key
|
||||
110 - Allowed Redirect
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
verifyVirtualApiKey
|
||||
} from "@server/auth/verifyVirtualApiKey";
|
||||
import {
|
||||
type RedirectByHost,
|
||||
getRedirectsByHost,
|
||||
getResourceByDomain,
|
||||
getResourceRules,
|
||||
getRoleResourceAccess,
|
||||
@@ -40,6 +42,8 @@ import {
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||
import { matchesPath } from "@server/lib/traefik/rule";
|
||||
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
|
||||
import { parseHttpMethodList } from "@server/lib/validators";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
@@ -68,6 +72,7 @@ import { APP_VERSION } from "@server/lib/consts";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type ResponseT from "@server/types/MessageResponse";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
sessions: z.record(z.string(), z.string()).optional(),
|
||||
@@ -109,6 +114,9 @@ export type VerifyUserResponse = {
|
||||
valid: boolean;
|
||||
headerAuthChallenged?: boolean;
|
||||
redirectUrl?: string;
|
||||
// Set alongside redirectUrl when the redirect is a configured Redirect
|
||||
// rather than a login bounce, so badger can answer 307 instead of 302.
|
||||
redirectPermanent?: boolean;
|
||||
userData?: BasicUserData;
|
||||
pangolinVersion?: string;
|
||||
dontStripSession?: boolean;
|
||||
@@ -193,6 +201,30 @@ export async function verifyResourceSession(
|
||||
cleanHost = cleanHost.slice(0, -1 * matched.length);
|
||||
}
|
||||
|
||||
// Redirects always win: they are routed ahead of resources in
|
||||
// Traefik and never require auth, even when attached to a resource,
|
||||
// so let a matching one through to the redirect middleware before
|
||||
// any resource lookup.
|
||||
const redirect = await findRedirect(cleanHost, path);
|
||||
if (redirect) {
|
||||
const redirectUrl = buildRedirectUrl(redirect, parsedBody.data);
|
||||
logger.debug(
|
||||
`Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})`
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 110, // redirected
|
||||
orgId: redirect.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return redirected(res, redirectUrl, redirect.permanent);
|
||||
}
|
||||
|
||||
const resourceCacheKey = `resource:${cleanHost}`;
|
||||
let resourceData:
|
||||
| {
|
||||
@@ -200,9 +232,7 @@ export async function verifyResourceSession(
|
||||
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth:
|
||||
| ResourceHeaderAuth
|
||||
| ResourcePolicyHeaderAuth
|
||||
| null;
|
||||
ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
@@ -1019,6 +1049,71 @@ function extractResourceSessionToken(
|
||||
return latest.token;
|
||||
}
|
||||
|
||||
async function findRedirect(
|
||||
host: string,
|
||||
path: string
|
||||
): Promise<RedirectByHost | null> {
|
||||
const cacheKey = `redirects:${host}`;
|
||||
let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey);
|
||||
if (!candidates) {
|
||||
candidates = await getRedirectsByHost(host);
|
||||
localCache.set(cacheKey, candidates, 5);
|
||||
}
|
||||
|
||||
// Candidates come back highest priority first, matching the order
|
||||
// Traefik evaluates the routers in.
|
||||
return (
|
||||
candidates.find((r) =>
|
||||
matchesPath(path, r.matchPath, r.pathMatchType)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destination for a configured redirect: the scheme and host come from the
|
||||
* redirect's destination, the request's query is kept and the path is run
|
||||
* through the redirect's rewrite rules (if any).
|
||||
*/
|
||||
function buildRedirectUrl(
|
||||
redirect: RedirectByHost,
|
||||
request: VerifyResourceSessionSchema
|
||||
): string {
|
||||
const newPath = rewriteRequestPath(
|
||||
request.path,
|
||||
redirect.matchPath,
|
||||
redirect.pathMatchType,
|
||||
redirect.rewritePath,
|
||||
redirect.rewritePathType
|
||||
);
|
||||
|
||||
let search = "";
|
||||
try {
|
||||
search = new URL(request.originalRequestURL).search;
|
||||
} catch {
|
||||
// originalRequestURL is validated as a URL, so this is only defensive
|
||||
}
|
||||
|
||||
return `${redirect.destinationHost}${newPath}${search}`;
|
||||
}
|
||||
|
||||
// Like a notAllowed login bounce, but the destination is the configured
|
||||
// redirect target rather than the auth page.
|
||||
function redirected(res: Response, redirectUrl: string, permanent: boolean) {
|
||||
const data = {
|
||||
data: {
|
||||
valid: true,
|
||||
redirectUrl,
|
||||
redirectPermanent: permanent,
|
||||
pangolinVersion: APP_VERSION
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Redirected",
|
||||
status: HttpCode.OK
|
||||
} satisfies ResponseT<VerifyUserResponse>;
|
||||
return response<VerifyUserResponse>(res, data);
|
||||
}
|
||||
|
||||
async function notAllowed(
|
||||
res: Response,
|
||||
redirectPath?: string,
|
||||
|
||||
@@ -62,6 +62,7 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||
import { checkRoundTripMessage } from "./ws";
|
||||
import * as labels from "@server/routers/labels";
|
||||
import * as aiProvider from "@server/routers/aiProvider";
|
||||
import * as redirect from "@server/routers/redirect";
|
||||
import * as aiBudget from "@server/routers/aiBudget";
|
||||
import * as virtualApiKey from "@server/routers/virtualApiKey";
|
||||
import * as certificates from "@server/routers/certificates";
|
||||
@@ -1670,6 +1671,50 @@ authenticated.delete(
|
||||
aiProvider.deleteAiProvider
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/redirect",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createRedirect),
|
||||
logActionAudit(ActionsEnum.createRedirect),
|
||||
redirect.createRedirect
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirects",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listRedirects),
|
||||
redirect.listRedirects
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getRedirect),
|
||||
redirect.getRedirect
|
||||
);
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirect/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getRedirect),
|
||||
redirect.getRedirect
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateRedirect),
|
||||
logActionAudit(ActionsEnum.updateRedirect),
|
||||
redirect.updateRedirect
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteRedirect),
|
||||
logActionAudit(ActionsEnum.deleteRedirect),
|
||||
redirect.deleteRedirect
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/ai-provider/:providerId/model",
|
||||
verifyAiProviderAccess,
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Org, primaryDb } 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 {
|
||||
idp,
|
||||
idpOidcConfig,
|
||||
idpOrg,
|
||||
orgs,
|
||||
Role,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import * as arctic from "arctic";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import jmespath from "jmespath";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import config from "@server/lib/config";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
||||
import {
|
||||
createSession,
|
||||
generateId,
|
||||
generateSessionToken,
|
||||
serializeSessionCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { LimitId } from "@server/lib/billing";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOidcConfig,
|
||||
idpOrg,
|
||||
Org,
|
||||
orgs,
|
||||
roles,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { LimitId } from "@server/lib/billing";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||
import { pushClientIdentitiesForUser } from "@server/lib/clientIdentities";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import response from "@server/lib/response";
|
||||
import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg";
|
||||
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import * as arctic from "arctic";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import jmespath from "jmespath";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
return url;
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as siteResource from "./siteResource";
|
||||
import * as aiProvider from "./aiProvider";
|
||||
import * as aiBudget from "./aiBudget";
|
||||
import * as virtualApiKey from "./virtualApiKey";
|
||||
import * as redirect from "./redirect";
|
||||
import * as labels from "./labels";
|
||||
import {
|
||||
verifyApiKey,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
verifyApiKeyAiModelAccess,
|
||||
verifyApiKeyAiBudgetAccess,
|
||||
verifyApiKeyVirtualApiKeyAccess,
|
||||
verifyApiKeyRedirectAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -1868,6 +1870,55 @@ authenticated.get(
|
||||
aiBudget.listAiBudgetsForVirtualApiKey
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/redirect",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createRedirect),
|
||||
logActionAudit(ActionsEnum.createRedirect),
|
||||
redirect.createRedirect
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirects",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listRedirects),
|
||||
redirect.listRedirects
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyRedirectAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getRedirect),
|
||||
redirect.getRedirect
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/redirect/:niceId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyRedirectAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getRedirect),
|
||||
redirect.getRedirect
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyRedirectAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateRedirect),
|
||||
logActionAudit(ActionsEnum.updateRedirect),
|
||||
redirect.updateRedirect
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/redirects/:redirectId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyRedirectAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteRedirect),
|
||||
logActionAudit(ActionsEnum.deleteRedirect),
|
||||
redirect.deleteRedirect
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/labels",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domains, orgDomains, redirects, resources } from "@server/db";
|
||||
import type { Domain, Redirect, Resource } 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 {
|
||||
redirectDestinationHostSchema,
|
||||
redirectMatchPathSchema,
|
||||
redirectPathMatchTypeSchema,
|
||||
redirectRewritePathSchema,
|
||||
isValidMatchPath,
|
||||
isAllowedSsl,
|
||||
redirectPrioritySchema,
|
||||
redirectRewritePathTypeSchema
|
||||
} from "@server/routers/redirect/validation";
|
||||
import { getUniqueRedirectName } from "@server/db/names";
|
||||
import { createCertificate } from "../certificates";
|
||||
|
||||
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(),
|
||||
destinationHost: redirectDestinationHostSchema,
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||
priority: redirectPrioritySchema.optional().nullable(),
|
||||
permanent: z.boolean().optional(),
|
||||
ssl: 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"]
|
||||
}
|
||||
)
|
||||
.refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), {
|
||||
message: "Exactly one of resourceId or domainId must be provided",
|
||||
path: ["resourceId"]
|
||||
})
|
||||
.refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), {
|
||||
message: "matchPath must be a valid regular expression",
|
||||
path: ["matchPath"]
|
||||
})
|
||||
.refine((data) => isAllowedSsl(data.ssl), {
|
||||
message: "TLS cannot be disabled on this build",
|
||||
path: ["ssl"]
|
||||
});
|
||||
|
||||
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,
|
||||
destinationHost,
|
||||
pathMatchType,
|
||||
matchPath,
|
||||
rewritePath,
|
||||
rewritePathType,
|
||||
priority,
|
||||
permanent,
|
||||
ssl,
|
||||
enabled
|
||||
} = parsedBody.data;
|
||||
|
||||
let resource: Resource | null = null;
|
||||
if (resourceId) {
|
||||
const res = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
resource = res.at(0) ?? null;
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let domain: Domain | null = null;
|
||||
if (domainId) {
|
||||
const res = await db
|
||||
.select()
|
||||
.from(domains)
|
||||
.innerJoin(
|
||||
orgDomains,
|
||||
eq(orgDomains.domainId, domains.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.domainId, domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
domain = res.at(0)?.domains ?? null;
|
||||
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,
|
||||
destinationHost,
|
||||
pathMatchType: pathMatchType ?? "regex",
|
||||
matchPath: matchPath ?? null,
|
||||
rewritePath: rewritePath ?? null,
|
||||
rewritePathType: rewritePathType ?? null,
|
||||
priority: priority ?? 100,
|
||||
permanent: permanent ?? false,
|
||||
// Resource-attached redirects follow the resource's ssl, so
|
||||
// the column is only meaningful for domain-attached ones.
|
||||
ssl: resource ? true : (ssl ?? true),
|
||||
enabled: enabled ?? true
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (domain) {
|
||||
const fullDomain = [subdomain ?? null, domain.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".");
|
||||
await createCertificate(domain.domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { redirects, db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
redirectId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/redirects/{redirectId}",
|
||||
description: "Delete a redirect.",
|
||||
tags: [OpenAPITags.Redirect],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteRedirect(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, redirectId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ redirectId: redirects.redirectId })
|
||||
.from(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.redirectId, redirectId),
|
||||
eq(redirects.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Redirect with ID ${redirectId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.redirectId, redirectId),
|
||||
eq(redirects.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Redirect deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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;
|
||||
destinationHost: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string | null;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
priority: number | null;
|
||||
permanent: boolean;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceDomainId: 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,
|
||||
destinationHost: redirects.destinationHost,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
rewritePath: redirects.rewritePath,
|
||||
rewritePathType: redirects.rewritePathType,
|
||||
priority: redirects.priority,
|
||||
permanent: redirects.permanent,
|
||||
ssl: redirects.ssl,
|
||||
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,
|
||||
resourceDomainId: resources.domainId
|
||||
};
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./createRedirect";
|
||||
export * from "./listRedirects";
|
||||
export * from "./getRedirect";
|
||||
export * from "./updateRedirect";
|
||||
export * from "./deleteRedirect";
|
||||
@@ -0,0 +1,299 @@
|
||||
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, desc, 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;
|
||||
destinationHost: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string | null;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
priority: number | null;
|
||||
permanent: boolean;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
resourceSsl: boolean | null;
|
||||
resourceDomainId: 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(),
|
||||
sort_by: z
|
||||
.enum(["name", "priority"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["name", "priority"],
|
||||
description: "Field to sort by"
|
||||
}),
|
||||
order: z
|
||||
.enum(["asc", "desc"])
|
||||
.optional()
|
||||
.default("asc")
|
||||
.catch("asc")
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
type: z
|
||||
.enum(["permanent", "temporary"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["permanent", "temporary"],
|
||||
description:
|
||||
"Filter redirects by type. `permanent` is a 308 redirect and `temporary` is a 307 redirect."
|
||||
}),
|
||||
enabled: z
|
||||
.enum(["true", "false"])
|
||||
.transform((value) => value === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter redirects based on enabled status"
|
||||
}),
|
||||
resourceId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description: "Filter redirects attached to this resource"
|
||||
}),
|
||||
domainId: z.string().nonempty().optional().catch(undefined).openapi({
|
||||
type: "string",
|
||||
description: "Filter redirects attached to this domain"
|
||||
})
|
||||
});
|
||||
|
||||
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,
|
||||
sort_by,
|
||||
order,
|
||||
type,
|
||||
enabled,
|
||||
resourceId,
|
||||
domainId
|
||||
} = parsedQuery.data;
|
||||
const conditions = [eq(redirects.orgId, orgId)];
|
||||
|
||||
if (type === "permanent") {
|
||||
conditions.push(eq(redirects.permanent, true));
|
||||
} else if (type === "temporary") {
|
||||
conditions.push(eq(redirects.permanent, false));
|
||||
}
|
||||
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(redirects.enabled, enabled));
|
||||
}
|
||||
|
||||
if (typeof resourceId !== "undefined") {
|
||||
conditions.push(eq(redirects.resourceId, resourceId));
|
||||
}
|
||||
|
||||
if (typeof domainId !== "undefined") {
|
||||
conditions.push(eq(redirects.domainId, domainId));
|
||||
}
|
||||
|
||||
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.destinationHost})`, term)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = db
|
||||
.select({
|
||||
redirectId: redirects.redirectId,
|
||||
orgId: redirects.orgId,
|
||||
niceId: redirects.niceId,
|
||||
name: redirects.name,
|
||||
subdomain: redirects.subdomain,
|
||||
destinationHost: redirects.destinationHost,
|
||||
pathMatchType: redirects.pathMatchType,
|
||||
matchPath: redirects.matchPath,
|
||||
rewritePath: redirects.rewritePath,
|
||||
rewritePathType: redirects.rewritePathType,
|
||||
priority: redirects.priority,
|
||||
permanent: redirects.permanent,
|
||||
ssl: redirects.ssl,
|
||||
enabled: redirects.enabled,
|
||||
resourceId: redirects.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId,
|
||||
resourceFullDomain: resources.fullDomain,
|
||||
resourceSsl: resources.ssl,
|
||||
resourceDomainId: resources.domainId,
|
||||
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")
|
||||
);
|
||||
|
||||
// Null priority is shown as 100, so sort on that same value.
|
||||
const prioritySort = sql`COALESCE(${redirects.priority}, 100)`;
|
||||
const orderBy =
|
||||
sort_by === "name"
|
||||
? order === "asc"
|
||||
? [asc(redirects.name), desc(redirects.redirectId)]
|
||||
: [desc(redirects.name), desc(redirects.redirectId)]
|
||||
: sort_by === "priority"
|
||||
? order === "asc"
|
||||
? [asc(prioritySort), desc(redirects.redirectId)]
|
||||
: [desc(prioritySort), desc(redirects.redirectId)]
|
||||
: [desc(prioritySort), desc(redirects.redirectId)];
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(...orderBy)
|
||||
]);
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
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,
|
||||
redirectDestinationHostSchema,
|
||||
redirectMatchPathSchema,
|
||||
redirectPathMatchTypeSchema,
|
||||
redirectRewritePathSchema,
|
||||
redirectRewritePathTypeSchema,
|
||||
redirectPrioritySchema,
|
||||
isValidMatchPath,
|
||||
isAllowedSsl
|
||||
} from "@server/routers/redirect/validation";
|
||||
import { createCertificate } from "../certificates";
|
||||
|
||||
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(),
|
||||
destinationHost: redirectDestinationHostSchema.optional(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||
rewritePath: redirectRewritePathSchema.optional().nullable(),
|
||||
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
|
||||
priority: redirectPrioritySchema.optional(),
|
||||
permanent: z.boolean().optional(),
|
||||
ssl: 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`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveResourceId =
|
||||
body.resourceId !== undefined
|
||||
? body.resourceId
|
||||
: existing.resourceId;
|
||||
const effectiveDomainId =
|
||||
body.domainId !== undefined ? body.domainId : existing.domainId;
|
||||
|
||||
if (Boolean(effectiveResourceId) === Boolean(effectiveDomainId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Exactly one of resourceId or domainId must be provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let domain: { domainId: string; baseDomain: string } | null = null;
|
||||
if (effectiveDomainId) {
|
||||
const [d] = await db
|
||||
.select({
|
||||
domainId: domains.domainId,
|
||||
baseDomain: domains.baseDomain
|
||||
})
|
||||
.from(domains)
|
||||
.innerJoin(
|
||||
orgDomains,
|
||||
eq(orgDomains.domainId, domains.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.domainId, effectiveDomainId),
|
||||
eq(orgDomains.orgId, existing.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
domain = d ?? null;
|
||||
if (!domain) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Domain with ID ${effectiveDomainId} 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`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isValidMatchPath(
|
||||
body.matchPath !== undefined
|
||||
? body.matchPath
|
||||
: existing.matchPath,
|
||||
body.pathMatchType ?? existing.pathMatchType
|
||||
)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"matchPath must be a valid regular expression"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAllowedSsl(body.ssl)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"TLS cannot be disabled on this build"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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.destinationHost !== undefined) {
|
||||
updateData.destinationHost = body.destinationHost;
|
||||
}
|
||||
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.priority !== undefined) {
|
||||
updateData.priority = body.priority;
|
||||
}
|
||||
if (body.permanent !== undefined) {
|
||||
updateData.permanent = body.permanent;
|
||||
}
|
||||
if (effectiveResourceId) {
|
||||
// Resource-attached redirects follow the resource's ssl; reset
|
||||
// the column so a later move back to a domain starts from TLS on.
|
||||
updateData.ssl = true;
|
||||
} else if (body.ssl !== undefined) {
|
||||
updateData.ssl = body.ssl;
|
||||
}
|
||||
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();
|
||||
|
||||
if (domain) {
|
||||
const effectiveSubdomain =
|
||||
body.subdomain !== undefined
|
||||
? body.subdomain
|
||||
: existing.subdomain;
|
||||
const fullDomain = [effectiveSubdomain ?? null, domain.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".");
|
||||
await createCertificate(domain.domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
import { isValidDomain } from "@server/lib/validators";
|
||||
import { build } from "@server/build";
|
||||
|
||||
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();
|
||||
|
||||
export function isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A regex match path is fed straight to `new RegExp` when building routes,
|
||||
* so reject patterns that would throw there.
|
||||
*/
|
||||
export function isValidMatchPath(
|
||||
matchPath: string | null | undefined,
|
||||
pathMatchType: string | null | undefined
|
||||
): boolean {
|
||||
return pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath);
|
||||
}
|
||||
|
||||
export const redirectRewritePathSchema = z.string().nonempty();
|
||||
|
||||
// Same range as target priorities; 100 means "let the system order it".
|
||||
export const redirectPrioritySchema = z.int().min(1).max(1000);
|
||||
|
||||
/**
|
||||
* A destination is `scheme://host[:port]` with no path, query or fragment;
|
||||
* the request path (after any rewrite) is appended to it by badger.
|
||||
*/
|
||||
export function isValidDestinationHost(value: string): boolean {
|
||||
if (URL.canParse(value)) {
|
||||
const url = new URL(value);
|
||||
/** URL origin contains the destination with no path or query */
|
||||
return value === url.origin;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const redirectDestinationHostSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine(isValidDestinationHost, {
|
||||
message:
|
||||
"Invalid destination, expected scheme://host such as https://example.com"
|
||||
});
|
||||
|
||||
/**
|
||||
* The cloud only serves HTTPS, so a domain-attached redirect may not opt out
|
||||
* of TLS there. Resource-attached redirects inherit the resource's ssl
|
||||
* setting and never carry their own.
|
||||
*/
|
||||
export function isAllowedSsl(ssl: boolean | undefined): boolean {
|
||||
return build !== "saas" || ssl !== false;
|
||||
}
|
||||
@@ -29,8 +29,8 @@ export async function traefikConfigProvider(
|
||||
const traefikConfig = await getTraefikConfig(
|
||||
currentExitNodeId,
|
||||
config.getRawConfig().traefik.site_types,
|
||||
build == "oss", // filter out the namespace domains in open source
|
||||
build != "oss", // generate the login pages on the cloud and and enterprise,
|
||||
build === "oss", // filter out the namespace domains in open source
|
||||
build !== "oss", // generate the login pages on the cloud and and enterprise,
|
||||
config.getRawConfig().traefik.allow_raw_resources,
|
||||
pangolinUIUrl,
|
||||
pangolinUIUrl,
|
||||
@@ -75,8 +75,7 @@ export async function traefikConfigProvider(
|
||||
.resource_session_request_param,
|
||||
|
||||
remoteUserIdHeader:
|
||||
config.getRawConfig().server.remote_headers
|
||||
.user_id,
|
||||
config.getRawConfig().server.remote_headers.user_id,
|
||||
|
||||
remoteVirtualApiKeyIdHeader:
|
||||
config.getRawConfig().server.remote_headers
|
||||
|
||||
@@ -281,6 +281,7 @@ export default function GeneralPage() {
|
||||
// 107 - Valid SSO
|
||||
// 108 - Connected Client
|
||||
// 109 - Valid Virtual API Key
|
||||
// 110 - Allowed Redirect
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
@@ -300,6 +301,7 @@ export default function GeneralPage() {
|
||||
107: t("validSSO"),
|
||||
108: t("connectedClient"),
|
||||
109: t("validVirtualAPIKey"),
|
||||
110: t("allowedRedirect"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
@@ -605,6 +607,7 @@ export default function GeneralPage() {
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "108", label: t("connectedClient") },
|
||||
{ value: "110", label: t("allowedRedirect") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
? {
|
||||
domainId: redirect.resourceDomainId,
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import RedirectsTable, {
|
||||
type RedirectFilterDomain
|
||||
} from "@app/components/RedirectsTable";
|
||||
import type { SelectedResource } from "@app/components/resource-selector";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import type { ListRedirectsResponse } from "@server/routers/redirect";
|
||||
import { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
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";
|
||||
|
||||
function parsePositiveInt(value: string | null): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
const { orgId } = await props.params;
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const t = await getTranslations();
|
||||
const header = await authCookieHeader();
|
||||
|
||||
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()}`,
|
||||
header
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
redirects = responseData.redirects;
|
||||
pagination = responseData.pagination;
|
||||
} catch {
|
||||
// empty list on error
|
||||
}
|
||||
|
||||
const resourceIdParam = parsePositiveInt(searchParams.get("resourceId"));
|
||||
let initialFilterResource: SelectedResource | null = null;
|
||||
if (resourceIdParam) {
|
||||
try {
|
||||
const resourceRes = await internal.get(
|
||||
`/resource/${resourceIdParam}`,
|
||||
header
|
||||
);
|
||||
const resource = (
|
||||
resourceRes.data as ResponseT<GetResourceResponse>
|
||||
).data;
|
||||
if (resource && resource.orgId === orgId) {
|
||||
initialFilterResource = {
|
||||
name: resource.name,
|
||||
resourceId: resource.resourceId,
|
||||
fullDomain: resource.fullDomain,
|
||||
niceId: resource.niceId,
|
||||
ssl: resource.ssl,
|
||||
wildcard: resource.wildcard
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// leave null so the table falls back to the id
|
||||
}
|
||||
}
|
||||
|
||||
const domainIdParam = searchParams.get("domainId");
|
||||
let initialFilterDomain: RedirectFilterDomain | null = null;
|
||||
if (domainIdParam) {
|
||||
try {
|
||||
const domainRes = await internal.get(
|
||||
`/org/${orgId}/domain/${domainIdParam}`,
|
||||
header
|
||||
);
|
||||
const domain = (domainRes.data as ResponseT<GetDomainResponse>)
|
||||
.data;
|
||||
if (domain) {
|
||||
initialFilterDomain = {
|
||||
domainId: domain.domainId,
|
||||
baseDomain: domain.baseDomain
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// leave null so the table falls back to the id
|
||||
}
|
||||
}
|
||||
|
||||
const redirectRows = redirects.map((redirect) => ({
|
||||
redirectId: redirect.redirectId,
|
||||
niceId: redirect.niceId,
|
||||
name: redirect.name,
|
||||
subdomain: redirect.subdomain,
|
||||
destinationHost: redirect.destinationHost,
|
||||
pathMatchType: redirect.pathMatchType,
|
||||
matchPath: redirect.matchPath,
|
||||
rewritePath: redirect.rewritePath,
|
||||
rewritePathType: redirect.rewritePathType,
|
||||
priority: redirect.priority,
|
||||
permanent: redirect.permanent,
|
||||
ssl: redirect.ssl,
|
||||
enabled: redirect.enabled,
|
||||
resourceId: redirect.resourceId,
|
||||
resourceName: redirect.resourceName,
|
||||
resourceNiceId: redirect.resourceNiceId,
|
||||
resourceFullDomain: redirect.resourceFullDomain,
|
||||
resourceSsl: redirect.resourceSsl,
|
||||
resourceDomainId: redirect.resourceDomainId,
|
||||
domainId: redirect.domainId,
|
||||
baseDomain: redirect.baseDomain
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("redirectsTitle")}
|
||||
description={t("redirectsDescription")}
|
||||
/>
|
||||
|
||||
<RedirectsTable
|
||||
orgId={orgId}
|
||||
redirects={redirectRows}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
initialFilterResource={initialFilterResource}
|
||||
initialFilterDomain={initialFilterDomain}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export default function PrivateResourceHttpPage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
{t("resourceTypeHttp")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
|
||||
@@ -62,7 +62,10 @@ export default async function PrivateResourceLayout(
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(modeSettingsKey),
|
||||
title:
|
||||
siteResource.mode === "http"
|
||||
? t("resourceTypeHttp")
|
||||
: t(modeSettingsKey),
|
||||
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode === "inference" ? "ai-gateway" : siteResource.mode}`
|
||||
},
|
||||
{
|
||||
|
||||
@@ -151,7 +151,7 @@ export default function CreatePrivateResourcePage() {
|
||||
},
|
||||
{
|
||||
value: "http" as const,
|
||||
title: t("createInternalResourceDialogModeHttp"),
|
||||
title: t("resourceTypeHttp"),
|
||||
description: t("privateResourceTypeHttpDescription")
|
||||
},
|
||||
{
|
||||
@@ -602,7 +602,7 @@ export default function CreatePrivateResourcePage() {
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("httpSettings")}
|
||||
{t("resourceTypeHttp")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t(
|
||||
|
||||
@@ -88,7 +88,10 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/general`
|
||||
},
|
||||
{
|
||||
title: t(`${resource.mode}Settings`),
|
||||
title:
|
||||
resource.mode === "http"
|
||||
? t("resourceTypeHttp")
|
||||
: t(`${resource.mode}Settings`),
|
||||
href: `/{orgId}/settings/resources/public/{niceId}/${resource.mode === "inference" ? "ai-gateway" : resource.mode}`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -770,7 +770,7 @@ export default function Page() {
|
||||
{ title: string; description: string }
|
||||
> = {
|
||||
http: {
|
||||
title: t("createInternalResourceDialogModeHttp"),
|
||||
title: t("resourceTypeHttp"),
|
||||
description: t("resourceTypeHttpDescription")
|
||||
},
|
||||
inference: {
|
||||
|
||||
+11
-2
@@ -10,7 +10,6 @@ import {
|
||||
Cable,
|
||||
ChartLine,
|
||||
Coins,
|
||||
Combine,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
@@ -21,11 +20,11 @@ import {
|
||||
LayoutGrid,
|
||||
Link as LinkIcon,
|
||||
Logs,
|
||||
MessageSquare,
|
||||
MessagesSquare,
|
||||
MonitorUp,
|
||||
Plug,
|
||||
ReceiptText,
|
||||
Repeat,
|
||||
ScanEye,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -141,6 +140,11 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/domains",
|
||||
icon: <Globe className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarRedirects",
|
||||
href: "/{orgId}/settings/redirects",
|
||||
icon: <Repeat className="size-4 flex-none" />
|
||||
},
|
||||
...(env?.flags.usePangolinDns
|
||||
? [
|
||||
{
|
||||
@@ -467,6 +471,11 @@ export const commandBarNavSections = (
|
||||
title: "commandMachineClients",
|
||||
icon: <Server className="size-4 flex-none" />
|
||||
},
|
||||
{
|
||||
title: "sidebarRedirects",
|
||||
href: "/{orgId}/settings/redirects",
|
||||
icon: <Repeat className="size-4 flex-none" />
|
||||
},
|
||||
...(env?.flags.usePangolinDns
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
DescribedSelect,
|
||||
type DescribedSelectOption
|
||||
} from "@app/components/DescribedSelect";
|
||||
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
@@ -24,6 +21,30 @@ import {
|
||||
} from "./Credenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type PathMatchType = "prefix" | "exact" | "regex";
|
||||
type RewritePathType = "prefix" | "exact" | "regex" | "stripPrefix";
|
||||
|
||||
const pathMatchTypes: PathMatchType[] = ["prefix", "exact", "regex"];
|
||||
|
||||
function isPathMatchType(
|
||||
value: string | null | undefined
|
||||
): value is PathMatchType {
|
||||
return pathMatchTypes.includes(value as PathMatchType);
|
||||
}
|
||||
|
||||
const rewritePathTypes: RewritePathType[] = [
|
||||
"prefix",
|
||||
"exact",
|
||||
"regex",
|
||||
"stripPrefix"
|
||||
];
|
||||
|
||||
function isRewritePathType(
|
||||
value: string | null | undefined
|
||||
): value is RewritePathType {
|
||||
return rewritePathTypes.includes(value as RewritePathType);
|
||||
}
|
||||
|
||||
export function PathMatchModal({
|
||||
value,
|
||||
onChange,
|
||||
@@ -39,20 +60,42 @@ export function PathMatchModal({
|
||||
const t = useTranslations();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [matchType, setMatchType] = useState(
|
||||
value?.pathMatchType || "prefix"
|
||||
const [matchType, setMatchType] = useState<PathMatchType>(
|
||||
isPathMatchType(value?.pathMatchType) ? value.pathMatchType : "prefix"
|
||||
);
|
||||
const [path, setPath] = useState(value?.path || "");
|
||||
|
||||
const matchOptions: DescribedSelectOption<PathMatchType>[] = [
|
||||
{
|
||||
value: "prefix",
|
||||
title: t("pathMatchPrefix"),
|
||||
description: t("pathMatchPrefixHelp")
|
||||
},
|
||||
{
|
||||
value: "exact",
|
||||
title: t("pathMatchExact"),
|
||||
description: t("pathMatchExactHelp")
|
||||
},
|
||||
{
|
||||
value: "regex",
|
||||
title: t("pathMatchRegex"),
|
||||
description: t("pathMatchRegexHelp")
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMatchType(value?.pathMatchType || "prefix");
|
||||
setMatchType(
|
||||
isPathMatchType(value?.pathMatchType)
|
||||
? value.pathMatchType
|
||||
: "prefix"
|
||||
);
|
||||
setPath(value?.path || "");
|
||||
}
|
||||
}, [open, value]);
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ pathMatchType: matchType as any, path: path.trim() });
|
||||
onChange({ pathMatchType: matchType, path: path.trim() });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
@@ -66,19 +109,6 @@ export function PathMatchModal({
|
||||
? t("pathMatchRegexPlaceholder")
|
||||
: t("pathMatchDefaultPlaceholder");
|
||||
|
||||
const getHelpText = () => {
|
||||
switch (matchType) {
|
||||
case "prefix":
|
||||
return t("pathMatchPrefixHelp");
|
||||
case "exact":
|
||||
return t("pathMatchExactHelp");
|
||||
case "regex":
|
||||
return t("pathMatchRegexHelp");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaTrigger asChild>{trigger}</CredenzaTrigger>
|
||||
@@ -91,23 +121,21 @@ export function PathMatchModal({
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody className="grid gap-4 space-y-0">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="match-type">{t("pathMatchType")}</Label>
|
||||
<Select value={matchType} onValueChange={setMatchType}>
|
||||
<SelectTrigger id="match-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prefix">
|
||||
{t("pathMatchPrefix")}
|
||||
</SelectItem>
|
||||
<SelectItem value="exact">
|
||||
{t("pathMatchExact")}
|
||||
</SelectItem>
|
||||
<SelectItem value="regex">
|
||||
{t("pathMatchRegex")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label>{t("pathMatchType")}</Label>
|
||||
<DescribedSelect<PathMatchType>
|
||||
options={matchOptions}
|
||||
value={matchType}
|
||||
onChange={setMatchType}
|
||||
searchPlaceholder={t("search")}
|
||||
emptyMessage={t("noResults")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
matchOptions.find(
|
||||
(option) => option.value === matchType
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="path-value">
|
||||
@@ -119,9 +147,6 @@ export function PathMatchModal({
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getHelpText()}
|
||||
</p>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter className="gap-2">
|
||||
@@ -155,21 +180,50 @@ export function PathRewriteModal({
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [rewriteType, setRewriteType] = useState(
|
||||
value?.rewritePathType || "prefix"
|
||||
const [rewriteType, setRewriteType] = useState<RewritePathType>(
|
||||
isRewritePathType(value?.rewritePathType)
|
||||
? value.rewritePathType
|
||||
: "prefix"
|
||||
);
|
||||
const [rewritePath, setRewritePath] = useState(value?.rewritePath || "");
|
||||
|
||||
const rewriteOptions: DescribedSelectOption<RewritePathType>[] = [
|
||||
{
|
||||
value: "prefix",
|
||||
title: t("pathRewritePrefix"),
|
||||
description: t("pathRewritePrefixHelp")
|
||||
},
|
||||
{
|
||||
value: "exact",
|
||||
title: t("pathRewriteExact"),
|
||||
description: t("pathRewriteExactHelp")
|
||||
},
|
||||
{
|
||||
value: "regex",
|
||||
title: t("pathRewriteRegex"),
|
||||
description: t("pathRewriteRegexHelp")
|
||||
},
|
||||
{
|
||||
value: "stripPrefix",
|
||||
title: t("pathRewriteStripPrefix"),
|
||||
description: t("pathRewriteStripPrefixHelp")
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRewriteType(value?.rewritePathType || "prefix");
|
||||
setRewriteType(
|
||||
isRewritePathType(value?.rewritePathType)
|
||||
? value.rewritePathType
|
||||
: "prefix"
|
||||
);
|
||||
setRewritePath(value?.rewritePath || "");
|
||||
}
|
||||
}, [open, value]);
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({
|
||||
rewritePathType: rewriteType as any,
|
||||
rewritePathType: rewriteType,
|
||||
rewritePath: rewritePath.trim()
|
||||
});
|
||||
setOpen(false);
|
||||
@@ -191,21 +245,6 @@ export function PathRewriteModal({
|
||||
}
|
||||
};
|
||||
|
||||
const getHelpText = () => {
|
||||
switch (rewriteType) {
|
||||
case "prefix":
|
||||
return t("pathRewritePrefixHelp");
|
||||
case "exact":
|
||||
return t("pathRewriteExactHelp");
|
||||
case "regex":
|
||||
return t("pathRewriteRegexHelp");
|
||||
case "stripPrefix":
|
||||
return t("pathRewriteStripPrefixHelp");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={(v) => !disabled && setOpen(v)}>
|
||||
<CredenzaTrigger asChild>{trigger}</CredenzaTrigger>
|
||||
@@ -218,31 +257,21 @@ export function PathRewriteModal({
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody className="grid gap-4 space-y-0">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rewrite-type">
|
||||
{t("pathRewriteType")}
|
||||
</Label>
|
||||
<Select
|
||||
<Label>{t("pathRewriteType")}</Label>
|
||||
<DescribedSelect<RewritePathType>
|
||||
options={rewriteOptions}
|
||||
value={rewriteType}
|
||||
onValueChange={setRewriteType}
|
||||
>
|
||||
<SelectTrigger id="rewrite-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prefix">
|
||||
{t("pathRewritePrefixOption")}
|
||||
</SelectItem>
|
||||
<SelectItem value="exact">
|
||||
{t("pathRewriteExactOption")}
|
||||
</SelectItem>
|
||||
<SelectItem value="regex">
|
||||
{t("pathRewriteRegexOption")}
|
||||
</SelectItem>
|
||||
<SelectItem value="stripPrefix">
|
||||
{t("pathRewriteStripPrefixOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={setRewriteType}
|
||||
searchPlaceholder={t("search")}
|
||||
emptyMessage={t("noResults")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{
|
||||
rewriteOptions.find(
|
||||
(option) => option.value === rewriteType
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rewrite-value">
|
||||
@@ -254,9 +283,6 @@ export function PathRewriteModal({
|
||||
value={rewritePath}
|
||||
onChange={(e) => setRewritePath(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getHelpText()}
|
||||
</p>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter className="gap-2">
|
||||
|
||||
@@ -49,6 +49,14 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionRestartOrgDomain")]: "restartOrgDomain"
|
||||
},
|
||||
|
||||
Redirect: {
|
||||
[t("actionCreateRedirect")]: "createRedirect",
|
||||
[t("actionDeleteRedirect")]: "deleteRedirect",
|
||||
[t("actionGetRedirect")]: "getRedirect",
|
||||
[t("actionListRedirects")]: "listRedirects",
|
||||
[t("actionUpdateRedirect")]: "updateRedirect"
|
||||
},
|
||||
|
||||
Site: {
|
||||
[t("actionCreateSite")]: "createSite",
|
||||
[t("actionDeleteSite")]: "deleteSite",
|
||||
|
||||
@@ -91,7 +91,7 @@ export function PrivateResourceInfoSections({
|
||||
const modeLabel: Record<PrivateResourceMode, string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
http: t("resourceTypeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference"),
|
||||
gateway: t("editInternalResourceDialogModeGateway")
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
"use client";
|
||||
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionGrid,
|
||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { build } from "@server/build";
|
||||
import type {
|
||||
CreateRedirectResponse,
|
||||
GetRedirectResponse
|
||||
} from "@server/routers/redirect";
|
||||
import {
|
||||
isValidDestinationHost,
|
||||
isValidRegex
|
||||
} from "@server/routers/redirect/validation";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { ChevronDown, ChevronUp, InfoIcon, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import { Alert, AlertDescription } from "./ui/alert";
|
||||
|
||||
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
|
||||
const DEFAULT_PRIORITY = 100;
|
||||
|
||||
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 [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<SelectedResource | null>(initialResource);
|
||||
|
||||
// DomainPicker only hands back the composed host through its callback, so
|
||||
// keep it locally; seed from the saved redirect for the edit case.
|
||||
const [domainFullDomain, setDomainFullDomain] = useState<string | null>(
|
||||
redirect?.baseDomain
|
||||
? [redirect.subdomain, redirect.baseDomain]
|
||||
.filter(Boolean)
|
||||
.join(".")
|
||||
: null
|
||||
);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
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(),
|
||||
destinationHost: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, {
|
||||
message: t("redirectDestinationHostRequired")
|
||||
})
|
||||
.refine(isValidDestinationHost, {
|
||||
message: t("redirectDestinationHostInvalid")
|
||||
}),
|
||||
pathMatchType: z.enum(["exact", "prefix", "regex"]),
|
||||
matchPath: z.string().trim().nullable(),
|
||||
rewritePath: z.string().nullable(),
|
||||
rewritePathType: z
|
||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||
.nullable(),
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { message: t("redirectPriorityInvalid") })
|
||||
.max(1000, { message: t("redirectPriorityInvalid") }),
|
||||
permanent: z.boolean(),
|
||||
ssl: 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"]
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.pathMatchType === "regex" &&
|
||||
data.matchPath &&
|
||||
!isValidRegex(data.matchPath)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: t("redirectMatchPathInvalidRegex"),
|
||||
path: ["matchPath"]
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
destinationHost: redirect?.destinationHost ?? "",
|
||||
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
|
||||
matchPath: redirect?.matchPath ?? null,
|
||||
rewritePath: redirect?.rewritePath ?? null,
|
||||
rewritePathType: redirect?.rewritePathType ?? null,
|
||||
priority: redirect?.priority ?? DEFAULT_PRIORITY,
|
||||
permanent: redirect?.permanent ?? false,
|
||||
ssl: redirect?.ssl ?? true,
|
||||
enabled: redirect?.enabled ?? true
|
||||
}
|
||||
});
|
||||
|
||||
const attachTo = form.watch("attachTo");
|
||||
const ssl = form.watch("ssl");
|
||||
|
||||
const resourceMissingDomain =
|
||||
attachTo === "resource" &&
|
||||
Boolean(selectedResource) &&
|
||||
!selectedResource?.domainId;
|
||||
|
||||
const sourceFullDomain =
|
||||
attachTo === "domain"
|
||||
? domainFullDomain
|
||||
: (selectedResource?.fullDomain ?? null);
|
||||
// Resource-attached redirects inherit the resource's ssl setting
|
||||
const sourceSsl =
|
||||
attachTo === "domain" ? ssl : (selectedResource?.ssl ?? true);
|
||||
const sourceHost = sourceFullDomain
|
||||
? `${sourceSsl ? "https" : "http"}://${sourceFullDomain}`
|
||||
: null;
|
||||
|
||||
// Mirror is UI-only state; on edit, infer it from whether the saved
|
||||
// destination already equals the source host.
|
||||
const [sameDomainAsSource, setSameDomainAsSource] = useState(
|
||||
Boolean(
|
||||
redirect && sourceHost && redirect.destinationHost === sourceHost
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sameDomainAsSource && sourceHost) {
|
||||
form.setValue("destinationHost", sourceHost, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
}, [sameDomainAsSource, sourceHost, form]);
|
||||
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() {
|
||||
if (!(await form.trigger())) return;
|
||||
|
||||
const values = form.getValues();
|
||||
|
||||
// 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,
|
||||
destinationHost: values.destinationHost.trim(),
|
||||
pathMatchType: values.pathMatchType,
|
||||
matchPath: values.matchPath?.trim() || null,
|
||||
rewritePath: values.rewritePath?.trim() || null,
|
||||
rewritePathType: values.rewritePathType,
|
||||
priority: values.priority,
|
||||
permanent: values.permanent,
|
||||
// Resource-attached redirects inherit the resource's ssl setting
|
||||
ssl: values.attachTo === "domain" ? values.ssl : true,
|
||||
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")
|
||||
});
|
||||
const niceId = res.data.data.redirect.niceId;
|
||||
router.push(`/${orgId}/settings/redirects/${niceId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<Form {...form}>
|
||||
<form
|
||||
action={formAction}
|
||||
id="redirect-form"
|
||||
className="space-y-6"
|
||||
>
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("general")}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-enabled"
|
||||
label={t("enabled")}
|
||||
description={t(
|
||||
"redirectEnabledDescription"
|
||||
)}
|
||||
checked={
|
||||
!resourceMissingDomain &&
|
||||
field.value
|
||||
}
|
||||
disabled={
|
||||
resourceMissingDomain
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setShowAdvancedSettings(
|
||||
!showAdvancedSettings
|
||||
)
|
||||
}
|
||||
className="mt-2 flex items-center gap-2 -ml-3"
|
||||
>
|
||||
{showAdvancedSettings ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
{t("advancedSettings")}
|
||||
</Button>
|
||||
</SettingsFormCell>
|
||||
{showAdvancedSettings && (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("priority")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
{...field}
|
||||
onChange={(
|
||||
e
|
||||
) =>
|
||||
field.onChange(
|
||||
e
|
||||
.target
|
||||
.valueAsNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectPriorityDescription"
|
||||
)}
|
||||
</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>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectSource")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectSourceSectionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm className="max-w-none">
|
||||
<SettingsFormGrid>
|
||||
{resourceMissingDomain && (
|
||||
<SettingsFormCell span="full">
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"redirectDomainSelectedDomainNotFound"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<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"
|
||||
description={t(
|
||||
"redirectAttachDomainDescription"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"redirectAttachDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="resource"
|
||||
description={t(
|
||||
"redirectAttachResourceDescription"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"redirectAttachResource"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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={
|
||||
false
|
||||
}
|
||||
defaultSubdomain={
|
||||
redirect?.subdomain
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res?.domainId ??
|
||||
null,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res?.subdomain ||
|
||||
null
|
||||
);
|
||||
setDomainFullDomain(
|
||||
res?.fullDomain ??
|
||||
null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
) : (
|
||||
<SettingsFormCell span="full">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* The cloud only serves HTTPS, and resource-attached redirects inherit TLS, so there is nothing to toggle in those cases. */}
|
||||
{build !== "saas" &&
|
||||
attachTo === "domain" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-ssl"
|
||||
label={t(
|
||||
"proxyEnableSSL"
|
||||
)}
|
||||
description={t(
|
||||
"redirectSslDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<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
|
||||
) => {
|
||||
// No match path
|
||||
// means the
|
||||
// redirect applies
|
||||
// to every path;
|
||||
// pathMatchType is
|
||||
// NOT NULL so it
|
||||
// keeps a default.
|
||||
field.onChange(
|
||||
config.path ||
|
||||
null
|
||||
);
|
||||
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"
|
||||
>
|
||||
{field.value ? (
|
||||
<PathMatchDisplay
|
||||
value={{
|
||||
path: field.value,
|
||||
pathMatchType:
|
||||
pathMatchType
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t(
|
||||
"matchPath"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectMatchPathDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectDestination")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectDestinationSectionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm className="max-w-none">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<SwitchInput
|
||||
id="redirect-same-domain"
|
||||
label={t(
|
||||
"redirectSameDomainAsSource"
|
||||
)}
|
||||
description={t(
|
||||
"redirectSameDomainAsSourceDescription"
|
||||
)}
|
||||
checked={sameDomainAsSource}
|
||||
onCheckedChange={
|
||||
setSameDomainAsSource
|
||||
}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationHost"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"redirectDestinationHost"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="https://example.com"
|
||||
readOnly={
|
||||
sameDomainAsSource
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectDestinationHostDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<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>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionGrid>
|
||||
|
||||
<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"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
>
|
||||
{isEditing ? t("saveSettings") : t("redirectAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
ResourceSelector,
|
||||
type SelectedResource
|
||||
} from "@app/components/resource-selector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
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 { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
CheckIcon,
|
||||
ChevronsUpDownIcon,
|
||||
Funnel,
|
||||
MinusIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ComponentRef,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export type RedirectRow = {
|
||||
redirectId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
subdomain: string | null;
|
||||
destinationHost: string;
|
||||
pathMatchType: "exact" | "prefix" | "regex";
|
||||
matchPath: string | null;
|
||||
rewritePath: string | null;
|
||||
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
|
||||
priority: number | null;
|
||||
permanent: boolean;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
resourceSsl: boolean | null;
|
||||
resourceDomainId: string | null;
|
||||
domainId: string | null;
|
||||
baseDomain: string | null;
|
||||
};
|
||||
|
||||
export type RedirectFilterDomain = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
};
|
||||
|
||||
type RedirectsTableProps = {
|
||||
redirects: RedirectRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
initialFilterResource?: SelectedResource | null;
|
||||
initialFilterDomain?: RedirectFilterDomain | null;
|
||||
};
|
||||
|
||||
export default function RedirectsTable({
|
||||
redirects,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount,
|
||||
initialFilterResource = null,
|
||||
initialFilterDomain = null
|
||||
}: RedirectsTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [selected, setSelected] = useState<RedirectRow | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
const [domainFilterOpen, setDomainFilterOpen] = useState(false);
|
||||
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
||||
|
||||
const domainIdQ = searchParams.get("domainId");
|
||||
const selectedDomain: RedirectFilterDomain | null = useMemo(() => {
|
||||
if (!domainIdQ) {
|
||||
return null;
|
||||
}
|
||||
if (initialFilterDomain && initialFilterDomain.domainId === domainIdQ) {
|
||||
return initialFilterDomain;
|
||||
}
|
||||
return {
|
||||
domainId: domainIdQ,
|
||||
baseDomain: t("redirectFilterDomainFallback", { id: domainIdQ })
|
||||
};
|
||||
}, [domainIdQ, initialFilterDomain, t]);
|
||||
|
||||
const resourceIdQ = searchParams.get("resourceId");
|
||||
const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN;
|
||||
const selectedResource: SelectedResource | null = useMemo(() => {
|
||||
if (
|
||||
!resourceIdQ ||
|
||||
!Number.isInteger(resourceIdNum) ||
|
||||
resourceIdNum <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
initialFilterResource &&
|
||||
initialFilterResource.resourceId === resourceIdNum
|
||||
) {
|
||||
return initialFilterResource;
|
||||
}
|
||||
return {
|
||||
name: t("redirectFilterResourceFallback", { id: resourceIdNum }),
|
||||
resourceId: resourceIdNum,
|
||||
fullDomain: null,
|
||||
niceId: "",
|
||||
ssl: false,
|
||||
wildcard: false
|
||||
};
|
||||
}, [initialFilterResource, resourceIdQ, resourceIdNum, t]);
|
||||
|
||||
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 handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null | string[]
|
||||
) {
|
||||
searchParams.delete(column);
|
||||
searchParams.delete("page");
|
||||
|
||||
if (typeof value === "string") {
|
||||
searchParams.set(column, value);
|
||||
} else if (value) {
|
||||
value.forEach((val) => searchParams.append(column, val));
|
||||
}
|
||||
|
||||
filter({ searchParams });
|
||||
}
|
||||
|
||||
const clearDomainFilter = () => {
|
||||
handleFilterChange("domainId", undefined);
|
||||
setDomainFilterOpen(false);
|
||||
};
|
||||
|
||||
const clearResourceFilter = () => {
|
||||
handleFilterChange("resourceId", undefined);
|
||||
setResourceFilterOpen(false);
|
||||
};
|
||||
|
||||
const onPickDomain = (domain: RedirectFilterDomain) => {
|
||||
handleFilterChange("domainId", domain.domainId);
|
||||
setDomainFilterOpen(false);
|
||||
};
|
||||
|
||||
const onPickResource = (resource: SelectedResource) => {
|
||||
handleFilterChange("resourceId", String(resource.resourceId));
|
||||
setResourceFilterOpen(false);
|
||||
};
|
||||
|
||||
function toggleSort(column: string) {
|
||||
filter({ searchParams: getNextSortOrder(column, searchParams) });
|
||||
}
|
||||
|
||||
// Prefix matches/rewrites cover everything beneath the path, so show the
|
||||
// implied glob rather than the bare prefix.
|
||||
function withPrefixGlob(path: string) {
|
||||
return path.endsWith("/") ? `${path}*` : `${path}/*`;
|
||||
}
|
||||
|
||||
async function toggleEnabled(enabled: boolean, redirectId: number) {
|
||||
try {
|
||||
await api.post(`/org/${orgId}/redirects/${redirectId}`, {
|
||||
enabled
|
||||
});
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
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}`);
|
||||
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,
|
||||
friendlyName: t("name"),
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <span>{row.original.name}</span>
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||
cell: ({ row }) => <span>{row.original.niceId}</span>
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
friendlyName: t("domain"),
|
||||
header: () => (
|
||||
<ColumnEntityFilter
|
||||
label={t("domain")}
|
||||
open={domainFilterOpen}
|
||||
onOpenChange={setDomainFilterOpen}
|
||||
selectedName={selectedDomain?.baseDomain}
|
||||
clearLabel={t("redirectFilterAnyDomain")}
|
||||
onClear={clearDomainFilter}
|
||||
>
|
||||
<DomainFilterList
|
||||
orgId={orgId}
|
||||
selectedDomainId={selectedDomain?.domainId ?? null}
|
||||
onSelect={onPickDomain}
|
||||
/>
|
||||
</ColumnEntityFilter>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
|
||||
if (!redirect.domainId || !redirect.baseDomain) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/domains/${redirect.domainId}`}
|
||||
>
|
||||
{redirect.baseDomain}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
friendlyName: t("resource"),
|
||||
header: () => (
|
||||
<ColumnEntityFilter
|
||||
label={t("resource")}
|
||||
open={resourceFilterOpen}
|
||||
onOpenChange={setResourceFilterOpen}
|
||||
selectedName={selectedResource?.name}
|
||||
clearLabel={t("redirectFilterAnyResource")}
|
||||
onClear={clearResourceFilter}
|
||||
>
|
||||
<ResourceSelector
|
||||
orgId={orgId}
|
||||
selectedResource={selectedResource}
|
||||
onSelectResource={onPickResource}
|
||||
/>
|
||||
</ColumnEntityFilter>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
|
||||
if (
|
||||
!redirect.resourceId ||
|
||||
!redirect.resourceName ||
|
||||
!redirect.resourceNiceId
|
||||
) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
|
||||
>
|
||||
{redirect.resourceName}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
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;
|
||||
|
||||
if (redirect.resourceId && !redirect.resourceDomainId) {
|
||||
return (
|
||||
<InfoPopup
|
||||
info={t("redirectDomainNotFoundDescription")}
|
||||
text={t("domainNotFound")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const matchPath = redirect.matchPath
|
||||
? redirect.pathMatchType === "prefix"
|
||||
? withPrefixGlob(redirect.matchPath)
|
||||
: redirect.matchPath
|
||||
: "";
|
||||
// Resource-attached redirects inherit the resource's ssl.
|
||||
const sourceSsl = redirect.resourceId
|
||||
? (redirect.resourceSsl ?? true)
|
||||
: redirect.ssl;
|
||||
const source = host
|
||||
? `${sourceSsl ? "https" : "http"}://${host}${matchPath}`
|
||||
: matchPath;
|
||||
|
||||
if (!source) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return <CopyToClipboard text={source} isLink={false} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
accessorKey: "destinationHost",
|
||||
friendlyName: t("redirectDestination"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("redirectDestination")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
const rewritePath = redirect.rewritePath
|
||||
? redirect.rewritePathType === "prefix"
|
||||
? withPrefixGlob(redirect.rewritePath)
|
||||
: redirect.rewritePath
|
||||
: "";
|
||||
const destination = `${redirect.destinationHost}${rewritePath}`;
|
||||
|
||||
if (!destination) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboard text={destination} isLink={false} />
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
friendlyName: t("priority"),
|
||||
header: () => {
|
||||
const priorityOrder = getSortDirection(
|
||||
"priority",
|
||||
searchParams
|
||||
);
|
||||
const Icon =
|
||||
priorityOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: priorityOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("priority")}
|
||||
>
|
||||
{t("priority")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
// 100 is the automatic default; anything else was set
|
||||
// deliberately, so flag which way it deviates.
|
||||
const priority = row.original.priority ?? 100;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{priority}
|
||||
{priority > 100 ? (
|
||||
<ArrowUp className="size-3 text-green-500" />
|
||||
) : priority < 100 ? (
|
||||
<ArrowDown className="size-3 text-red-500" />
|
||||
) : (
|
||||
<MinusIcon className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "permanent",
|
||||
friendlyName: t("redirectType"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "permanent",
|
||||
label: t("redirectTypePermanent")
|
||||
},
|
||||
{
|
||||
value: "temporary",
|
||||
label: t("redirectTypeTemporary")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("type") ?? undefined}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("redirectType")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{row.original.permanent
|
||||
? t("redirectTypePermanent")
|
||||
: t("redirectTypeTemporary")}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("enabled") },
|
||||
{ value: "false", label: t("disabled") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("enabled")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("enabled", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("enabled")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RedirectEnabledForm
|
||||
redirect={row.original}
|
||||
onToggleEnabled={toggleEnabled}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
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,
|
||||
searchParams,
|
||||
domainFilterOpen,
|
||||
resourceFilterOpen,
|
||||
selectedDomain,
|
||||
selectedResource
|
||||
]
|
||||
);
|
||||
|
||||
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={redirects}
|
||||
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={{
|
||||
niceId: false,
|
||||
priority: false
|
||||
}}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type RedirectEnabledFormProps = {
|
||||
redirect: RedirectRow;
|
||||
onToggleEnabled: (val: boolean, redirectId: number) => Promise<void>;
|
||||
};
|
||||
|
||||
function RedirectEnabledForm({
|
||||
redirect,
|
||||
onToggleEnabled
|
||||
}: RedirectEnabledFormProps) {
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
|
||||
redirect.enabled
|
||||
);
|
||||
|
||||
const missingDomain = Boolean(
|
||||
redirect.resourceId && !redirect.resourceDomainId
|
||||
);
|
||||
|
||||
const formRef = useRef<ComponentRef<"form">>(null);
|
||||
|
||||
async function submitAction(formData: FormData) {
|
||||
if (missingDomain) return;
|
||||
const newEnabled = !(formData.get("enabled") === "on");
|
||||
setOptimisticEnabled(newEnabled);
|
||||
await onToggleEnabled(newEnabled, redirect.redirectId);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} ref={formRef}>
|
||||
<Switch
|
||||
checked={!missingDomain && optimisticEnabled}
|
||||
disabled={
|
||||
missingDomain || optimisticEnabled !== redirect.enabled
|
||||
}
|
||||
name="enabled"
|
||||
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type ColumnEntityFilterProps = {
|
||||
label: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedName?: string | null;
|
||||
clearLabel: string;
|
||||
onClear: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function ColumnEntityFilter({
|
||||
label,
|
||||
open,
|
||||
onOpenChange,
|
||||
selectedName,
|
||||
clearLabel,
|
||||
onClear,
|
||||
children
|
||||
}: ColumnEntityFilterProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedName && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{label}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedName && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<div className="border-b p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start font-normal"
|
||||
onClick={onClear}
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type DomainFilterListProps = {
|
||||
orgId: string;
|
||||
selectedDomainId: string | null;
|
||||
onSelect: (domain: RedirectFilterDomain) => void;
|
||||
};
|
||||
|
||||
function DomainFilterList({
|
||||
orgId,
|
||||
selectedDomainId,
|
||||
onSelect
|
||||
}: DomainFilterListProps) {
|
||||
const t = useTranslations();
|
||||
const { data: domains = [] } = useQuery(orgQueries.domains({ orgId }));
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder={t("domainsSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("domainsNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{domains.map((domain) => (
|
||||
<CommandItem
|
||||
key={domain.domainId}
|
||||
value={domain.baseDomain}
|
||||
onSelect={() =>
|
||||
onSelect({
|
||||
domainId: domain.domainId,
|
||||
baseDomain: domain.baseDomain
|
||||
})
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
domain.domainId === selectedDomainId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{domain.baseDomain}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
+68
-16
@@ -1,23 +1,46 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-6">{children}</div>;
|
||||
export function SettingsContainer({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-6", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
export function SettingsSection({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
|
||||
return (
|
||||
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({
|
||||
@@ -77,7 +100,7 @@ export function SettingsFormCell({
|
||||
"min-w-0",
|
||||
span === "quarter" && "md:col-span-1",
|
||||
span === "half" && "md:col-span-2",
|
||||
span === "full" && "md:col-span-4",
|
||||
span === "full" && "col-span-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -87,23 +110,36 @@ export function SettingsFormCell({
|
||||
}
|
||||
|
||||
export function SettingsSectionTitle({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-1xl font-semibold tracking-tight flex items-center gap-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionDescription({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <p className="text-muted-foreground text-sm">{children}</p>;
|
||||
return (
|
||||
<p className={cn("text-muted-foreground text-sm", className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSubsectionHeader({
|
||||
@@ -141,11 +177,15 @@ export function SettingsSubsectionDescription({
|
||||
}
|
||||
|
||||
export function SettingsSectionBody({
|
||||
children
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className="space-y-5 flex-grow">{children}</div>;
|
||||
return (
|
||||
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSectionFooter({
|
||||
@@ -169,10 +209,22 @@ export function SettingsSectionFooter({
|
||||
|
||||
export function SettingsSectionGrid({
|
||||
children,
|
||||
cols
|
||||
cols = 4,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols: number;
|
||||
cols?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// @ts-expect-error
|
||||
"--cols": `repeat(${cols}, minmax(0, 1fr))`
|
||||
}}
|
||||
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useDebounce } from "use-debounce";
|
||||
export type SelectedResource = Pick<
|
||||
ListResourcesResponse["resources"][number],
|
||||
"name" | "resourceId" | "fullDomain" | "niceId" | "ssl" | "wildcard"
|
||||
>;
|
||||
> & { domainId?: string | null };
|
||||
|
||||
export type ResourceSelectorProps = {
|
||||
orgId: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"read-only:opacity-50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
|
||||
className
|
||||
|
||||
@@ -20,7 +20,9 @@ const getStoredColumnVisibility = (
|
||||
const parsed = JSON.parse(stored);
|
||||
// Validate that it's an object
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed;
|
||||
// Stored choices win. Keys only present in the defaults
|
||||
// (for example a newly hidden column) still apply.
|
||||
return { ...(defaultVisibility || {}), ...parsed };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -60,8 +62,7 @@ export function useStoredColumnVisibility(
|
||||
const setColumnVisibility = useCallback(
|
||||
(
|
||||
updaterOrValue:
|
||||
| VisibilityState
|
||||
| ((old: VisibilityState) => VisibilityState)
|
||||
VisibilityState | ((old: VisibilityState) => VisibilityState)
|
||||
) => {
|
||||
if (typeof updaterOrValue === "function") {
|
||||
setVisibility((oldValue) => {
|
||||
|
||||
Reference in New Issue
Block a user