mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-22 18:43:15 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97c91fbdc9 | |||
| 57b80bf74e | |||
| d06a261cd6 | |||
| 67f45023db | |||
| 2cb5cea48f | |||
| 2f5e3eede6 | |||
| 85adae063b | |||
| ec36317057 | |||
| 5ca08d71f0 | |||
| 1f453dc04f | |||
| c51bcbb578 | |||
| a0c77a4c85 | |||
| b58cf31856 | |||
| fd7780528f | |||
| 4a41e6b650 | |||
| 4bf85ead77 | |||
| f19b2c391f | |||
| 5a6c23e05a | |||
| 4324eebc1f | |||
| 7ce2668b5b | |||
| 9e85b39fd5 | |||
| 30ca3e7e97 | |||
| 2bccdc33fc | |||
| 05d1d4d143 | |||
| ae7315244f | |||
| 241ecc13e2 | |||
| 82c5dcf16f | |||
| 59f0c90836 | |||
| b0e64a5e5a | |||
| 733d3ece0e | |||
| 59b228ce39 | |||
| 080bcbaf97 |
+2
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import yargs from "yargs";
|
||||
import { hideBin } from "yargs/helpers";
|
||||
import { setAdminCredentials } from "./commands/setAdminCredentials";
|
||||
import { resetUserSecurityKeys } from "./commands/resetUserSecurityKeys";
|
||||
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
||||
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
||||
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||
image: docker.io/fosrl/pangolin:ee-latest
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||
image: docker.io/fosrl/pangolin:ee-latest
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
+56
-2
@@ -132,7 +132,7 @@
|
||||
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
|
||||
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
|
||||
"siteRestarted": "Site restarted",
|
||||
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
|
||||
"siteRestartedDescription": "The site has been restarted.",
|
||||
"siteErrorRestart": "Failed to restart site",
|
||||
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
||||
"siteSettingDescription": "Configure the settings on the site",
|
||||
@@ -2123,6 +2123,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",
|
||||
@@ -3568,6 +3569,7 @@
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"validVirtualAPIKey": "Valid Virtual API Key",
|
||||
"allowedRedirect": "Allowed Redirect",
|
||||
"view": "View",
|
||||
"configManaged": "Config Managed",
|
||||
"connectedClient": "Connected Client",
|
||||
@@ -4400,5 +4402,57 @@
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar",
|
||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||
"check": "Check"
|
||||
"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",
|
||||
"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. Redirects are always evaluated before targets.",
|
||||
"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 this redirect applies to",
|
||||
"redirectSettingsDescription": "Set which paths to match and where to send them",
|
||||
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
|
||||
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
|
||||
"redirectAttachDomain": "Domain",
|
||||
"redirectAttachResource": "Resource",
|
||||
"redirectDomainRequired": "Select a domain to attach this redirect to",
|
||||
"redirectResourceRequired": "Select a resource to attach this redirect to",
|
||||
"redirectPermanent": "Permanent Redirect",
|
||||
"redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.",
|
||||
"redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.",
|
||||
"redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting."
|
||||
}
|
||||
|
||||
+4
-4
@@ -459,13 +459,13 @@
|
||||
"searchApiKeys": "API sleutels zoeken...",
|
||||
"apiKeysAdd": "API sleutel genereren",
|
||||
"apiKeysErrorDelete": "Fout bij verwijderen API sleutel",
|
||||
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API- leutel",
|
||||
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API sleutel",
|
||||
"apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?",
|
||||
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de APIsleutel niet meer worden gebruikt.",
|
||||
"apiKeysDeleteConfirm": "Bevestig verwijderen API sleutel",
|
||||
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de API sleutel niet meer worden gebruikt.",
|
||||
"apiKeysDeleteConfirm": "Bevestig Verwijderen API sleutel",
|
||||
"apiKeysDelete": "API sleutel verwijderen",
|
||||
"apiKeysManage": "API sleutels beheren",
|
||||
"apiKeysDescription": "API sleutels worden gebruikt om te verifiëren met de integratie-API",
|
||||
"apiKeysDescription": "API sleutels worden gebruikt om toegang te verifiëren met de integratie API ",
|
||||
"orgsManage": "Organisaties Beheren",
|
||||
"orgsDescription": "Bekijk en beheer alle organisaties op dit systeem",
|
||||
"provisioningKeysTitle": "Vertrekkende sleutel",
|
||||
|
||||
Generated
+100
-701
File diff suppressed because it is too large
Load Diff
+19
-19
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1136.0",
|
||||
"@aws-sdk/client-s3": "3.1131.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
@@ -66,10 +66,10 @@
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.2",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.103.1",
|
||||
"@tanstack/react-table": "9.2.4",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -96,20 +96,20 @@
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.4.2",
|
||||
"js-yaml": "5.4.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lru-cache": "11.5.3",
|
||||
"lucide-react": "1.47.0",
|
||||
"lru-cache": "11.5.2",
|
||||
"lucide-react": "1.45.0",
|
||||
"maxmind": "5.0.7",
|
||||
"moment": "2.31.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.3.5",
|
||||
"next-intl": "4.14.5",
|
||||
"next-intl": "4.14.4",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"nodemailer": "10.0.10",
|
||||
"nodemailer": "10.0.9",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.23.0",
|
||||
"posthog-node": "5.52.4",
|
||||
"posthog-node": "5.52.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.3.0",
|
||||
"react-day-picker": "10.0.1",
|
||||
@@ -123,7 +123,7 @@
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.6.2",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
@@ -135,15 +135,15 @@
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.1",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.6.5",
|
||||
"zod": "4.6.2",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "2.28.0",
|
||||
"@dotenvx/dotenvx": "2.24.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.9.5",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tanstack/react-query-devtools": "5.103.1",
|
||||
"@tanstack/react-query-devtools": "5.102.8",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -155,8 +155,8 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "26.6.2",
|
||||
"@types/nodemailer": "8.0.2",
|
||||
"@types/node": "26.5.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.23.1",
|
||||
"@types/react": "19.3.0",
|
||||
@@ -171,10 +171,10 @@
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.2",
|
||||
"esbuild-node-externals": "2.0.0",
|
||||
"eslint": "10.11.0",
|
||||
"eslint": "10.10.0",
|
||||
"eslint-config-next": "16.3.5",
|
||||
"postcss": "8.5.28",
|
||||
"prettier": "3.9.8",
|
||||
"prettier": "3.9.6",
|
||||
"react-email": "6.9.5",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tsc-alias": "1.9.5",
|
||||
|
||||
@@ -205,7 +205,12 @@ export enum ActionsEnum {
|
||||
deleteVirtualApiKey = "deleteVirtualApiKey",
|
||||
getVirtualApiKey = "getVirtualApiKey",
|
||||
listVirtualApiKeys = "listVirtualApiKeys",
|
||||
updateVirtualApiKey = "updateVirtualApiKey"
|
||||
updateVirtualApiKey = "updateVirtualApiKey",
|
||||
createRedirect = "createRedirect",
|
||||
deleteRedirect = "deleteRedirect",
|
||||
getRedirect = "getRedirect",
|
||||
listRedirects = "listRedirects",
|
||||
updateRedirect = "updateRedirect"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -227,6 +227,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",
|
||||
{
|
||||
@@ -2067,6 +2102,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, or, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export type ResourceWithAuth = {
|
||||
@@ -53,11 +55,74 @@ 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), eq(resources.enabled, true)),
|
||||
inArray(redirectHost, candidates)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(redirects.priority));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource by domain with pincode and password information
|
||||
*/
|
||||
|
||||
@@ -243,6 +243,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",
|
||||
{
|
||||
@@ -2106,6 +2143,7 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
|
||||
>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
export type Redirect = InferSelectModel<typeof redirects>;
|
||||
export type AiProvider = InferSelectModel<typeof aiProviders>;
|
||||
export type AiModel = InferSelectModel<typeof aiModels>;
|
||||
export type AiBudget = InferSelectModel<typeof aiBudgets>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatBackupFileName, formatBackupTimestamp } from "./backupFileName";
|
||||
import { formatBackupTimestamp } from "./backupFileName";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
// Local-time constructors are used throughout, matching formatBackupTimestamp,
|
||||
@@ -29,9 +29,7 @@ function testMonthIsOneIndexed() {
|
||||
}
|
||||
|
||||
{
|
||||
const result = formatBackupTimestamp(
|
||||
new Date(2026, 11, 31, 23, 59, 59)
|
||||
);
|
||||
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
|
||||
assertEquals(
|
||||
result,
|
||||
"2026-12-31_23-59-59",
|
||||
@@ -75,7 +73,9 @@ function testNamesSortChronologically() {
|
||||
new Date(2026, 11, 31, 23, 59, 59)
|
||||
];
|
||||
|
||||
const sorted = taken.map((date) => formatBackupTimestamp(date)).sort();
|
||||
const sorted = taken
|
||||
.map((date) => formatBackupTimestamp(date))
|
||||
.sort();
|
||||
|
||||
assertEquals(
|
||||
sorted.join(","),
|
||||
@@ -89,48 +89,11 @@ function testNamesSortChronologically() {
|
||||
);
|
||||
}
|
||||
|
||||
function testFormatBackupFileName() {
|
||||
console.log("Running backup file name formatting tests...");
|
||||
|
||||
const date = new Date(2026, 8, 12, 20, 35, 56);
|
||||
|
||||
// With semver version string without leading 'v'
|
||||
assertEquals(
|
||||
formatBackupFileName("1.22.0", date),
|
||||
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
|
||||
"Filename must include timestamp and prefixed version tag"
|
||||
);
|
||||
|
||||
// With version string already containing 'v'
|
||||
assertEquals(
|
||||
formatBackupFileName("v1.22.0", date),
|
||||
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
|
||||
"Filename must not duplicate 'v' prefix if already present"
|
||||
);
|
||||
|
||||
// Without version (fallback/default)
|
||||
assertEquals(
|
||||
formatBackupFileName(undefined, date),
|
||||
"db_2026-09-12_20-35-56.sqlite",
|
||||
"Filename without version must match default timestamped format"
|
||||
);
|
||||
|
||||
// Distinct versions within the exact same second do not collide
|
||||
const sameSecondFile1 = formatBackupFileName("1.21.0", date);
|
||||
const sameSecondFile2 = formatBackupFileName("1.22.0", date);
|
||||
if (sameSecondFile1 === sameSecondFile2) {
|
||||
throw new Error(
|
||||
"Backup file names for different versions in the same second must not collide"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
testMonthIsOneIndexed();
|
||||
testEveryFieldIsZeroPadded();
|
||||
testNamesSortChronologically();
|
||||
testFormatBackupFileName();
|
||||
console.log("All tests passed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
|
||||
@@ -26,26 +26,3 @@ export function formatBackupTimestamp(date: Date = new Date()): string {
|
||||
|
||||
return `${datePart}_${timePart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full database backup file name, including timestamp and optional version tag.
|
||||
*
|
||||
* When a migration version is provided, the filename includes `_v<version>`,
|
||||
* preventing collisions between multiple migrations running in the same second and making it easy
|
||||
* to identify the migration state contained in the backup.
|
||||
*
|
||||
* @param version Optional migration version being run.
|
||||
* @param date The moment the backup is being taken. Defaults to now.
|
||||
* @returns A filename of the form `db_YYYY-MM-DD_HH-MM-SS_v<version>.sqlite` or `db_YYYY-MM-DD_HH-MM-SS.sqlite`.
|
||||
*/
|
||||
export function formatBackupFileName(
|
||||
version?: string,
|
||||
date: Date = new Date()
|
||||
): string {
|
||||
const timestamp = formatBackupTimestamp(date);
|
||||
if (version) {
|
||||
const versionTag = version.startsWith("v") ? version : `v${version}`;
|
||||
return `db_${timestamp}_${versionTag}.sqlite`;
|
||||
}
|
||||
return `db_${timestamp}.sqlite`;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@ function getSegmentRegex(patternPart: string): RegExp {
|
||||
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
|
||||
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment
|
||||
// or a wildcard-swallowed sequence under `/public/*`.
|
||||
//
|
||||
// Applied to both the request path and the rule pattern: the pattern
|
||||
// validator only accepts spaces / non-ASCII in percent-encoded form, so a
|
||||
// rule like `/my%20docs/*` must be compared against the decoded segment
|
||||
// `my docs`, not the literal text `my%20docs`.
|
||||
function decodeAndResolvePath(p: string): string[] {
|
||||
const rawParts = p.split("/").filter(Boolean);
|
||||
|
||||
@@ -53,7 +48,7 @@ function decodeAndResolvePath(p: string): string[] {
|
||||
}
|
||||
|
||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
const patternParts = decodeAndResolvePath(pattern);
|
||||
const patternParts = pattern.split("/").filter(Boolean);
|
||||
const pathParts = decodeAndResolvePath(path);
|
||||
|
||||
function matchSegments(
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
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,
|
||||
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,
|
||||
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";
|
||||
@@ -128,6 +129,7 @@ export async function getTraefikConfig(
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.exitNodeSubnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
|
||||
// Domain cert resolver fields
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert
|
||||
@@ -326,12 +328,87 @@ 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), eq(resources.enabled, true))
|
||||
)
|
||||
)
|
||||
.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 {};
|
||||
}
|
||||
@@ -518,6 +595,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,
|
||||
|
||||
@@ -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,127 @@
|
||||
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`;
|
||||
|
||||
logger.debug(
|
||||
`Processing redirect ${redirect.name} with domain ${redirect.fullDomain}`
|
||||
);
|
||||
|
||||
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,42 @@ 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"
|
||||
| "tlsServerName"
|
||||
| "setHostHeader"
|
||||
| "enableProxy"
|
||||
| "headers"
|
||||
| "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[];
|
||||
};
|
||||
|
||||
@@ -70,8 +70,13 @@ export async function verifyApiKeyAccess(
|
||||
);
|
||||
}
|
||||
|
||||
if (!apiKey.apiKeyOrg?.orgId) {
|
||||
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID`));
|
||||
if (!apiKeyOrg.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`API key with ID ${apiKeyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
@@ -81,7 +86,7 @@ export async function verifyApiKeyAccess(
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, apiKey.apiKeyOrg.orgId)
|
||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ export enum OpenAPITags {
|
||||
AiProvider = "AI Provider",
|
||||
AiModel = "AI Model",
|
||||
AiBudget = "AI Budget",
|
||||
VirtualApiKey = "Virtual API Key"
|
||||
VirtualApiKey = "Virtual API Key",
|
||||
Redirect = "Redirect"
|
||||
}
|
||||
|
||||
// Order here controls the order tags are displayed in Swagger UI
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
domains,
|
||||
exitNodes,
|
||||
loginPage,
|
||||
redirects,
|
||||
SiteResource,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
@@ -56,7 +57,7 @@ import {
|
||||
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 { ResourceWithTargets } from "@server/lib/traefik/types";
|
||||
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
|
||||
import {
|
||||
buildHostRule,
|
||||
@@ -85,6 +86,10 @@ import {
|
||||
buildBrowserGatewayConfig
|
||||
} from "@server/lib/traefik/browserGateway";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
|
||||
import {
|
||||
buildRedirectConfig,
|
||||
RedirectRouteRow
|
||||
} from "@server/lib/traefik/redirect";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||
@@ -214,7 +219,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)) {
|
||||
@@ -242,7 +247,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(
|
||||
@@ -296,7 +301,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,
|
||||
@@ -396,6 +401,89 @@ 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), eq(resources.enabled, true))
|
||||
)
|
||||
)
|
||||
.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.
|
||||
@@ -435,6 +523,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)}`);
|
||||
@@ -467,9 +561,14 @@ export async function getTraefikConfig(
|
||||
}
|
||||
};
|
||||
|
||||
console.dir(
|
||||
{ resourcesMap, resourcesWithTargetsAndSites },
|
||||
{ depth: null }
|
||||
);
|
||||
|
||||
// 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`;
|
||||
@@ -592,7 +691,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;
|
||||
});
|
||||
@@ -781,6 +880,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,
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function exportConnectionAuditLogs(
|
||||
|
||||
const baseQuery = queryConnection(data);
|
||||
|
||||
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -386,38 +386,6 @@ function runSpecialCharacterTests() {
|
||||
console.log("All special character tests passed!");
|
||||
}
|
||||
|
||||
function runEncodedPatternTests() {
|
||||
console.log("\nRunning percent-encoded pattern tests...");
|
||||
|
||||
// isValidUrlGlobPattern accepts percent-encoded sequences and rejects
|
||||
// raw spaces / non-ASCII, so `%20` and `%C3%A9` are the only way to write
|
||||
// a PATH rule for such a path. Badger sends the request path already
|
||||
// decoded (Go's req.URL.Path), and isPathAllowed decodes it again, so the
|
||||
// rule pattern must be decoded the same way or it can never match.
|
||||
assertEquals(
|
||||
isPathAllowed("/my%20docs/*", "/my docs/report.pdf"),
|
||||
true,
|
||||
"Percent-encoded space in pattern should match decoded request path"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("/my%20docs/*", "/my%20docs/report.pdf"),
|
||||
true,
|
||||
"Percent-encoded space in pattern should match raw-encoded request path"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("/caf%C3%A9", "/café"),
|
||||
true,
|
||||
"Percent-encoded UTF-8 in pattern should match decoded request path"
|
||||
);
|
||||
assertEquals(
|
||||
isPathAllowed("/my%20docs/*", "/my-docs/report.pdf"),
|
||||
false,
|
||||
"Decoded pattern must still reject a different path"
|
||||
);
|
||||
|
||||
console.log("All percent-encoded pattern tests passed!");
|
||||
}
|
||||
|
||||
function runRegionTests() {
|
||||
console.log("\nRunning isIpInRegion tests...");
|
||||
|
||||
@@ -478,7 +446,6 @@ function runRegionTests() {
|
||||
try {
|
||||
runTests();
|
||||
runSpecialCharacterTests();
|
||||
runEncodedPatternTests();
|
||||
runRegionTests();
|
||||
console.log("\n✅ All tests passed!");
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -67,6 +71,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(),
|
||||
@@ -108,6 +113,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;
|
||||
@@ -192,6 +200,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:
|
||||
| {
|
||||
@@ -199,9 +231,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;
|
||||
@@ -1009,6 +1039,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";
|
||||
@@ -1634,6 +1635,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,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
// Error codes for registration failures
|
||||
export const NewtErrorCodes = {
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Helper function to send registration error
|
||||
export async function sendNewtError(
|
||||
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
||||
newtId: string
|
||||
) {
|
||||
sendToClient(newtId, {
|
||||
type: "newt/error",
|
||||
data: {
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.error(
|
||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||
);
|
||||
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function addPeer(
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
if (!newt) {
|
||||
throw new Error(`Newt not found for site ${siteId}`);
|
||||
throw new Error(`Site found for site ${siteId}`);
|
||||
}
|
||||
newtId = newt.newtId;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
|
||||
HOLEPUNCH_MISSING: {
|
||||
code: "HOLEPUNCH_MISSING",
|
||||
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
||||
},
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,186 @@
|
||||
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;
|
||||
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
|
||||
};
|
||||
|
||||
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,203 @@
|
||||
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;
|
||||
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()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/redirects",
|
||||
description: "List redirects for an organization.",
|
||||
tags: [OpenAPITags.Redirect],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: listSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listRedirects(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { pageSize, page, query } = parsedQuery.data;
|
||||
const conditions = [eq(redirects.orgId, orgId)];
|
||||
|
||||
if (query) {
|
||||
const term = "%" + query.toLowerCase() + "%";
|
||||
conditions.push(
|
||||
or(
|
||||
like(sql`LOWER(${redirects.name})`, term),
|
||||
like(sql`LOWER(${redirects.matchPath})`, term),
|
||||
like(sql`LOWER(${redirects.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,
|
||||
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")
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
countQuery,
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(desc(redirects.priority), desc(redirects.redirectId))
|
||||
]);
|
||||
|
||||
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,75 @@
|
||||
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 {
|
||||
const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value);
|
||||
return match !== null && isValidDomain(match[1]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -500,8 +500,7 @@ async function updateHttpResource(
|
||||
}
|
||||
|
||||
// catch when the resource policy changes or gets cleared
|
||||
if (updateData.resourcePolicyId !== undefined &&
|
||||
resource.resourcePolicyId !== updateData.resourcePolicyId) {
|
||||
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
|
||||
@@ -263,7 +263,7 @@ export async function createSite(
|
||||
const { value: newClientAddress, release } =
|
||||
await getNextAvailableClientSubnet(orgId);
|
||||
releaseSubnetLock = release;
|
||||
updatedAddress = `${newClientAddress.split("/")[0]}/${org.subnet ? org.subnet.split("/")[1] : "32"}`;
|
||||
updatedAddress = newClientAddress.split("/")[0];
|
||||
}
|
||||
|
||||
let newSite: Site | undefined;
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function updateSite(
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, updateData.niceId),
|
||||
eq(sites.orgId, existingSite.orgId),
|
||||
eq(sites.orgId, sites.orgId),
|
||||
ne(sites.siteId, siteId)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { getSiteResourceParamsSchema } from "./getSiteResource";
|
||||
|
||||
function testSiteResourceIdOnlyParams() {
|
||||
const result = getSiteResourceParamsSchema.safeParse({
|
||||
siteResourceId: "42"
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
result.success,
|
||||
true,
|
||||
"siteResourceId-only integration routes should pass validation"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
assertEquals(
|
||||
result.data.siteResourceId,
|
||||
42,
|
||||
"siteResourceId should be parsed as a number"
|
||||
);
|
||||
assertEquals(
|
||||
result.data.orgId,
|
||||
undefined,
|
||||
"orgId should remain optional"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function testOrgScopedParamsRemainSupported() {
|
||||
const result = getSiteResourceParamsSchema.safeParse({
|
||||
siteResourceId: "42",
|
||||
orgId: "org-id"
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
result.success,
|
||||
true,
|
||||
"org-scoped routes should continue to pass validation"
|
||||
);
|
||||
}
|
||||
|
||||
function testInvalidSiteResourceId() {
|
||||
const result = getSiteResourceParamsSchema.safeParse({
|
||||
siteResourceId: "not-a-number"
|
||||
});
|
||||
|
||||
assertEquals(
|
||||
result.success,
|
||||
false,
|
||||
"non-numeric siteResourceIds should fail validation"
|
||||
);
|
||||
}
|
||||
|
||||
testSiteResourceIdOnlyParams();
|
||||
testOrgScopedParamsRemainSupported();
|
||||
testInvalidSiteResourceId();
|
||||
|
||||
console.log("All getSiteResource parameter validation tests passed.");
|
||||
@@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
export const getSiteResourceParamsSchema = z.strictObject({
|
||||
const getSiteResourceParamsSchema = z.strictObject({
|
||||
siteResourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -22,17 +22,15 @@ export const getSiteResourceParamsSchema = z.strictObject({
|
||||
});
|
||||
|
||||
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
|
||||
if (siteResourceId) {
|
||||
if (siteResourceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
orgId
|
||||
? and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
: eq(siteResources.siteResourceId, siteResourceId)
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
|
||||
@@ -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,
|
||||
@@ -71,8 +71,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
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import Database from "better-sqlite3";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "..", "..");
|
||||
const migrationsScript = path.join(here, "migrationsSqlite.ts");
|
||||
|
||||
const SEED_STATEMENTS = [
|
||||
`CREATE TABLE versionMigrations (version TEXT PRIMARY KEY, executedAt INTEGER NOT NULL)`,
|
||||
`INSERT INTO versionMigrations (version, executedAt) VALUES ('1.21.0', 1750000000000)`,
|
||||
`CREATE TABLE sites (siteId INTEGER PRIMARY KEY AUTOINCREMENT, subnet TEXT)`,
|
||||
`INSERT INTO sites (subnet) VALUES ('10.0.0.0/24')`,
|
||||
`CREATE TABLE roles (roleId INTEGER PRIMARY KEY AUTOINCREMENT, orgId TEXT, isAdmin INTEGER DEFAULT 0, sshSudoMode TEXT DEFAULT 'none')`,
|
||||
`INSERT INTO roles (orgId, isAdmin, sshSudoMode) VALUES ('org1', 0, 'none')`,
|
||||
`CREATE TABLE licenseKey (licenseKeyId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||
`CREATE TABLE targets (targetId INTEGER PRIMARY KEY AUTOINCREMENT, resourceId INTEGER, siteId INTEGER NOT NULL, ip TEXT NOT NULL, method TEXT, port INTEGER NOT NULL, internalPort INTEGER, enabled INTEGER DEFAULT 1, path TEXT, pathMatchType TEXT, rewritePath TEXT, rewritePathType TEXT, priority INTEGER DEFAULT 100, mode TEXT DEFAULT 'http', authToken TEXT)`,
|
||||
`CREATE TABLE subscriptions (subscriptionId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||
`CREATE TABLE clients (clientId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||
`CREATE TABLE orgs (orgId TEXT PRIMARY KEY)`,
|
||||
`INSERT INTO orgs (orgId) VALUES ('org1')`,
|
||||
`CREATE TABLE siteResources (siteResourceId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||
`CREATE TABLE eventStreamingDestinations (destinationId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||
`CREATE TABLE roleActions (roleId INTEGER, actionId TEXT, orgId TEXT)`,
|
||||
`CREATE TABLE newt (newtId INTEGER PRIMARY KEY AUTOINCREMENT)`
|
||||
];
|
||||
|
||||
function seedDatabase(dbPath: string) {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
for (const statement of SEED_STATEMENTS) {
|
||||
db.exec(statement);
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function tableColumns(dbPath: string, tableName: string): string[] {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
return (
|
||||
db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||
name: unknown;
|
||||
}>
|
||||
).map((row) => String(row.name));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function executedMigrationVersions(dbPath: string): string[] {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
return (
|
||||
db.prepare(`SELECT version FROM versionMigrations`).all() as Array<{
|
||||
version: unknown;
|
||||
}>
|
||||
).map((row) => String(row.version));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(
|
||||
workdir: string,
|
||||
env: Record<string, string> = {}
|
||||
): {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
} {
|
||||
const tsconfig = ["tsconfig.json", "tsconfig.oss.json"]
|
||||
.map((file) => path.join(repoRoot, file))
|
||||
.find((file) => fs.existsSync(file));
|
||||
if (!tsconfig) {
|
||||
throw new Error("No tsconfig found for @server path aliases");
|
||||
}
|
||||
const tsxCli = path.join(
|
||||
repoRoot,
|
||||
"node_modules",
|
||||
"tsx",
|
||||
"dist",
|
||||
"cli.mjs"
|
||||
);
|
||||
if (!fs.existsSync(tsxCli)) {
|
||||
throw new Error("tsx is not installed; run npm ci first");
|
||||
}
|
||||
try {
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[tsxCli, "--tsconfig", tsconfig, migrationsScript],
|
||||
{
|
||||
cwd: workdir,
|
||||
timeout: 120000,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, NODE_ENV: "test", ...env }
|
||||
}
|
||||
);
|
||||
return { exitCode: 0, output };
|
||||
} catch (error) {
|
||||
const output =
|
||||
error instanceof Error
|
||||
? (error as Error & { stdout?: unknown }).stdout
|
||||
: "";
|
||||
return { exitCode: 1, output: String(output ?? "") };
|
||||
}
|
||||
}
|
||||
|
||||
function createTestEnvironment(): string {
|
||||
for (const generated of ["server/build.ts", "server/db/index.ts"]) {
|
||||
if (!fs.existsSync(path.join(repoRoot, generated))) {
|
||||
throw new Error(
|
||||
`Missing ${generated}; run npm run set:oss && npm run set:sqlite first`
|
||||
);
|
||||
}
|
||||
}
|
||||
const workdir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "pangolin-backup-test-")
|
||||
);
|
||||
fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, "config", "config.example.yml"),
|
||||
path.join(workdir, "config", "config.yml")
|
||||
);
|
||||
const traefikSrc = path.join(repoRoot, "config", "traefik");
|
||||
if (fs.existsSync(traefikSrc)) {
|
||||
fs.cpSync(traefikSrc, path.join(workdir, "config", "traefik"), {
|
||||
recursive: true
|
||||
});
|
||||
}
|
||||
fs.symlinkSync(
|
||||
path.join(repoRoot, "server"),
|
||||
path.join(workdir, "server"),
|
||||
process.platform === "win32" ? "junction" : "dir"
|
||||
);
|
||||
return workdir;
|
||||
}
|
||||
|
||||
function testMultipleSequentialMigrations() {
|
||||
console.log("Running multiple sequential migrations test...");
|
||||
const workdir = createTestEnvironment();
|
||||
try {
|
||||
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
|
||||
const result = runMigrations(workdir);
|
||||
assertEquals(result.exitCode, 0, "Seeded migrations must run cleanly");
|
||||
if (!result.output.includes("All migrations completed successfully")) {
|
||||
throw new Error(
|
||||
"Seeded migrations did not complete; the backup assertions below would be vacuous"
|
||||
);
|
||||
}
|
||||
|
||||
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||
const backups = fs.existsSync(backupsDir)
|
||||
? fs
|
||||
.readdirSync(backupsDir)
|
||||
.filter((file) => file.endsWith(".sqlite"))
|
||||
: [];
|
||||
|
||||
// Upgrading from 1.21.0 runs 1.22.0 and 1.23.0 -> produces 2 distinct backups
|
||||
assertEquals(
|
||||
backups.length,
|
||||
2,
|
||||
"Each migration must have its own distinct backup snapshot"
|
||||
);
|
||||
|
||||
const v122Backup = backups.find((file) =>
|
||||
file.includes("_v1.22.0.sqlite")
|
||||
);
|
||||
const v123Backup = backups.find((file) =>
|
||||
file.includes("_v1.23.0.sqlite")
|
||||
);
|
||||
|
||||
if (!v122Backup || !v123Backup) {
|
||||
throw new Error(
|
||||
`Expected backups for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify pre-1.22.0 snapshot state: sites has 'subnet' (not exitNodeSubnet), versions = [1.21.0]
|
||||
const v122Columns = tableColumns(
|
||||
path.join(backupsDir, v122Backup),
|
||||
"sites"
|
||||
);
|
||||
assertEquals(
|
||||
v122Columns.includes("subnet") &&
|
||||
!v122Columns.includes("exitNodeSubnet"),
|
||||
true,
|
||||
"Backup before 1.22.0 must retain pre-1.22.0 schema (sites.subnet)"
|
||||
);
|
||||
const v122Versions = executedMigrationVersions(
|
||||
path.join(backupsDir, v122Backup)
|
||||
);
|
||||
assertEquals(
|
||||
v122Versions.includes("1.21.0") && !v122Versions.includes("1.22.0"),
|
||||
true,
|
||||
"Backup before 1.22.0 must only record version 1.21.0"
|
||||
);
|
||||
|
||||
// Verify pre-1.23.0 snapshot state: sites has 'exitNodeSubnet' (1.22.0 applied), newt has no agent
|
||||
const v123Columns = tableColumns(
|
||||
path.join(backupsDir, v123Backup),
|
||||
"sites"
|
||||
);
|
||||
assertEquals(
|
||||
v123Columns.includes("exitNodeSubnet"),
|
||||
true,
|
||||
"Backup before 1.23.0 must contain successfully applied 1.22.0 schema (sites.exitNodeSubnet)"
|
||||
);
|
||||
const v123NewtCols = tableColumns(
|
||||
path.join(backupsDir, v123Backup),
|
||||
"newt"
|
||||
);
|
||||
assertEquals(
|
||||
!v123NewtCols.includes("agent"),
|
||||
true,
|
||||
"Backup before 1.23.0 must not contain 1.23.0 schema changes yet"
|
||||
);
|
||||
const v123Versions = executedMigrationVersions(
|
||||
path.join(backupsDir, v123Backup)
|
||||
);
|
||||
assertEquals(
|
||||
v123Versions.includes("1.21.0") && v123Versions.includes("1.22.0"),
|
||||
true,
|
||||
"Backup before 1.23.0 must record both 1.21.0 and 1.22.0"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function testFailureInLaterMigrationPreservesRestorePoints() {
|
||||
console.log("Running failure in later migration test...");
|
||||
const workdir = createTestEnvironment();
|
||||
try {
|
||||
const dbPath = path.join(workdir, "config", "db", "db.sqlite");
|
||||
seedDatabase(dbPath);
|
||||
|
||||
// Intentionally drop table 'newt' so migration 1.23.0 fails on ALTER TABLE newt ADD COLUMN agent
|
||||
const db = new Database(dbPath);
|
||||
db.exec("DROP TABLE newt;");
|
||||
db.close();
|
||||
|
||||
const result = runMigrations(workdir);
|
||||
assertEquals(
|
||||
result.exitCode,
|
||||
1,
|
||||
"Migration suite must fail when 1.23.0 errors"
|
||||
);
|
||||
|
||||
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||
const backups = fs.existsSync(backupsDir)
|
||||
? fs
|
||||
.readdirSync(backupsDir)
|
||||
.filter((file) => file.endsWith(".sqlite"))
|
||||
: [];
|
||||
|
||||
// Both pre-1.22.0 and pre-1.23.0 backups must exist
|
||||
assertEquals(
|
||||
backups.length,
|
||||
2,
|
||||
"Backups for earlier successful migration and the failed migration must both exist"
|
||||
);
|
||||
|
||||
const v122Backup = backups.find((file) =>
|
||||
file.includes("_v1.22.0.sqlite")
|
||||
);
|
||||
const v123Backup = backups.find((file) =>
|
||||
file.includes("_v1.23.0.sqlite")
|
||||
);
|
||||
|
||||
if (!v122Backup || !v123Backup) {
|
||||
throw new Error(
|
||||
`Expected restore points for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify pre-1.23.0 backup is a valid restore point with 1.22.0 changes applied
|
||||
const v123SitesCols = tableColumns(
|
||||
path.join(backupsDir, v123Backup),
|
||||
"sites"
|
||||
);
|
||||
assertEquals(
|
||||
v123SitesCols.includes("exitNodeSubnet"),
|
||||
true,
|
||||
"Pre-failure restore point must have 1.22.0 changes intact"
|
||||
);
|
||||
const v123Versions = executedMigrationVersions(
|
||||
path.join(backupsDir, v123Backup)
|
||||
);
|
||||
assertEquals(
|
||||
v123Versions.includes("1.22.0"),
|
||||
true,
|
||||
"Pre-failure restore point must record successful 1.22.0 migration"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function testDisableBackupOnMigration() {
|
||||
console.log("Running DISABLE_BACKUP_ON_MIGRATION test...");
|
||||
const workdir = createTestEnvironment();
|
||||
try {
|
||||
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
|
||||
const result = runMigrations(workdir, {
|
||||
DISABLE_BACKUP_ON_MIGRATION: "1"
|
||||
});
|
||||
assertEquals(
|
||||
result.exitCode,
|
||||
0,
|
||||
"Migrations must succeed with backups disabled"
|
||||
);
|
||||
|
||||
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||
const backups = fs.existsSync(backupsDir)
|
||||
? fs
|
||||
.readdirSync(backupsDir)
|
||||
.filter((file) => file.endsWith(".sqlite"))
|
||||
: [];
|
||||
assertEquals(
|
||||
backups.length,
|
||||
0,
|
||||
"No backup files should be created when DISABLE_BACKUP_ON_MIGRATION is set"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
testMultipleSequentialMigrations();
|
||||
testFailureInLaterMigrationPreservesRestorePoints();
|
||||
testDisableBackupOnMigration();
|
||||
console.log("All backup migration regression tests passed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import path from "path";
|
||||
import semver from "semver";
|
||||
import { versionMigrations } from "../db/sqlite";
|
||||
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||
import { formatBackupFileName } from "@server/lib/backupFileName";
|
||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
||||
import { SqliteError } from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import { build } from "@server/build";
|
||||
@@ -107,7 +107,7 @@ async function run() {
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
function backupDb(version?: string) {
|
||||
function backupDb() {
|
||||
// make dir config/db/backups
|
||||
const appPath = APP_PATH;
|
||||
const dbDir = path.join(appPath, "db");
|
||||
@@ -120,10 +120,11 @@ function backupDb(version?: string) {
|
||||
}
|
||||
|
||||
// copy the db.sqlite file to backups
|
||||
// add the date and migration version to the filename
|
||||
const fileName = formatBackupFileName(version);
|
||||
// add the date to the filename
|
||||
const date = new Date();
|
||||
const dateString = formatBackupTimestamp(date);
|
||||
const dbPath = path.join(dbDir, "db.sqlite");
|
||||
const backupPath = path.join(backupsDir, fileName);
|
||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
}
|
||||
|
||||
@@ -162,12 +163,6 @@ export async function runMigrations() {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error running migrations:", e);
|
||||
if (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.ENVIRONMENT === "test"
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
||||
);
|
||||
@@ -202,7 +197,7 @@ async function executeScripts() {
|
||||
try {
|
||||
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
|
||||
// Backup the database before running the migration
|
||||
backupDb(migration.version);
|
||||
backupDb();
|
||||
}
|
||||
|
||||
await migration.run();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
users
|
||||
} from "../../db/sqlite";
|
||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
@@ -20,6 +21,25 @@ import { fromZodError } from "zod-validation-error";
|
||||
export default async function migration() {
|
||||
console.log("Running setup script 1.0.0-beta.9...");
|
||||
|
||||
// make dir config/db/backups
|
||||
const appPath = APP_PATH;
|
||||
const dbDir = path.join(appPath, "db");
|
||||
|
||||
const backupsDir = path.join(dbDir, "backups");
|
||||
|
||||
// check if the backups directory exists and create it if it doesn't
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// copy the db.sqlite file to backups
|
||||
// add the date to the filename
|
||||
const date = new Date();
|
||||
const dateString = formatBackupTimestamp(date);
|
||||
const dbPath = path.join(dbDir, "db.sqlite");
|
||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
try {
|
||||
// Determine which config file exists
|
||||
|
||||
@@ -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,73 @@
|
||||
import RedirectForm from "@app/components/RedirectForm";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import type { GetRedirectResponse } from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Edit Redirect"
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type EditRedirectPageProps = {
|
||||
params: Promise<{ orgId: string; niceId: string }>;
|
||||
};
|
||||
|
||||
export default async function EditRedirectPage(props: EditRedirectPageProps) {
|
||||
const { orgId, niceId } = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let redirect: GetRedirectResponse["redirect"];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<GetRedirectResponse>>(
|
||||
`/org/${orgId}/redirect/${niceId}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
redirect = res.data.data.redirect;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// The resource selector needs the resource's display fields up front so the
|
||||
// trigger shows a name instead of a bare id before the list query resolves.
|
||||
const initialResource =
|
||||
redirect.resourceId && redirect.resourceNiceId
|
||||
? {
|
||||
resourceId: redirect.resourceId,
|
||||
niceId: redirect.resourceNiceId,
|
||||
name: redirect.resourceName ?? redirect.resourceNiceId,
|
||||
fullDomain: redirect.resourceFullDomain,
|
||||
ssl: redirect.resourceSsl ?? false,
|
||||
wildcard: redirect.resourceWildcard ?? false
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<SettingsSectionTitle
|
||||
title={redirect.name}
|
||||
description={t("redirectEditDescription")}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/redirects`}>
|
||||
{t("redirectGoBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RedirectForm
|
||||
orgId={orgId}
|
||||
redirect={redirect}
|
||||
initialResource={initialResource}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,121 @@
|
||||
import RedirectsTable from "@app/components/RedirectsTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { build } from "@server/build";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { ListRedirectsResponse } from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Redirects"
|
||||
};
|
||||
|
||||
type RedirectIndexPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
|
||||
const { orgId } = await props.params;
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
const t = await getTranslations();
|
||||
|
||||
let redirects: ListRedirectsResponse["redirects"] = [];
|
||||
let pagination: ListRedirectsResponse["pagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListRedirectsResponse>>(
|
||||
`/org/${orgId}/redirects?${searchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
const responseData = res.data.data;
|
||||
redirects = responseData.redirects;
|
||||
pagination = responseData.pagination;
|
||||
} catch {
|
||||
// empty list on error
|
||||
}
|
||||
|
||||
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,
|
||||
enabled: redirect.enabled,
|
||||
resourceId: redirect.resourceId,
|
||||
resourceName: redirect.resourceName,
|
||||
resourceNiceId: redirect.resourceNiceId,
|
||||
resourceFullDomain: redirect.resourceFullDomain,
|
||||
resourceDomainId: redirect.resourceDomainId,
|
||||
domainId: redirect.domainId,
|
||||
baseDomain: redirect.baseDomain
|
||||
}));
|
||||
|
||||
// Prefetched in one batched call so the table doesn't fire a separate
|
||||
// certificate request per visible row once it mounts on the client.
|
||||
const certDomains = Array.from(
|
||||
new Set(
|
||||
redirectRows
|
||||
.map((r) => {
|
||||
const domainHost = r.baseDomain
|
||||
? [r.subdomain, r.baseDomain].filter(Boolean).join(".")
|
||||
: null;
|
||||
return r.resourceFullDomain ?? domainHost;
|
||||
})
|
||||
.filter((host): host is string => Boolean(host))
|
||||
)
|
||||
);
|
||||
|
||||
let initialCertificates: GetBatchedCertificateResponse | undefined;
|
||||
if (build !== "oss" && certDomains.length > 0) {
|
||||
try {
|
||||
const certSearchParams = new URLSearchParams(
|
||||
certDomains.map((domain) => ["domains", domain])
|
||||
);
|
||||
const certRes = await internal.get<
|
||||
AxiosResponse<GetBatchedCertificateResponse>
|
||||
>(
|
||||
`/org/${orgId}/batched-certificates?${certSearchParams.toString()}`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
initialCertificates = certRes.data.data;
|
||||
} catch {
|
||||
// leave undefined so each row falls back to fetching its own
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("redirectsTitle")}
|
||||
description={t("redirectsDescription")}
|
||||
/>
|
||||
|
||||
<RedirectsTable
|
||||
orgId={orgId}
|
||||
redirects={redirectRows}
|
||||
rowCount={pagination.total}
|
||||
pagination={{
|
||||
pageIndex: pagination.page - 1,
|
||||
pageSize: pagination.pageSize
|
||||
}}
|
||||
initialCertificates={initialCertificates}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
isValidDestinationHost,
|
||||
isValidRegex
|
||||
} from "@server/routers/redirect/validation";
|
||||
import { build } from "@server/build";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type {
|
||||
CreateRedirectResponse,
|
||||
GetRedirectResponse
|
||||
} from "@server/routers/redirect";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import {
|
||||
PathMatchDisplay,
|
||||
PathMatchModal,
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { Plus } from "lucide-react";
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import Link from "next/link";
|
||||
|
||||
const DEFAULT_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 [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
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 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 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")
|
||||
});
|
||||
router.push(`/${orgId}/settings/redirects/`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
isEditing
|
||||
? t("redirectErrorUpdate")
|
||||
: t("redirectErrorCreate")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectDeleted")
|
||||
});
|
||||
router.push(`/${orgId}/settings/redirects`);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("redirectErrorDelete"),
|
||||
description: formatAxiosError(e, t("redirectErrorDelete"))
|
||||
});
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={setIsDeleteModalOpen}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("redirectQuestionRemove")}</p>
|
||||
<p>{t("redirectMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("redirectDeleteConfirm")}
|
||||
onConfirm={onDelete}
|
||||
string={redirect!.name}
|
||||
title={t("redirectDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectSource")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectSourceSectionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form action={formAction} id="redirect-form">
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-enabled"
|
||||
label={t(
|
||||
"enabled"
|
||||
)}
|
||||
description={t(
|
||||
"redirectEnabledDescription"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"redirectAttachedTo"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="domain">
|
||||
{t(
|
||||
"redirectAttachDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="resource">
|
||||
{t(
|
||||
"redirectAttachResource"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectAttachedToDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{attachTo === "domain" ? (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
orgId={orgId}
|
||||
cols={1}
|
||||
hideFreeDomain
|
||||
defaultDomainId={
|
||||
redirect?.domainId
|
||||
}
|
||||
allowWildcard
|
||||
defaultSubdomain={
|
||||
redirect?.subdomain
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
form.setValue(
|
||||
"domainId",
|
||||
res?.domainId ??
|
||||
null,
|
||||
{
|
||||
shouldValidate: true
|
||||
}
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
res?.subdomain ||
|
||||
null
|
||||
);
|
||||
setDomainFullDomain(
|
||||
res?.fullDomain ??
|
||||
null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
) : (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="resourceId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"selectedRedirectResource"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between",
|
||||
!field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedResource?.name ??
|
||||
t(
|
||||
"resourceSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<ResourceSelector
|
||||
orgId={
|
||||
orgId
|
||||
}
|
||||
selectedResource={
|
||||
selectedResource
|
||||
}
|
||||
onSelectResource={(
|
||||
resource
|
||||
) => {
|
||||
setSelectedResource(
|
||||
resource
|
||||
);
|
||||
field.onChange(
|
||||
resource.resourceId
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{attachTo === "resource" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("resourceDomain")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
disabled
|
||||
readOnly
|
||||
value={
|
||||
selectedResource?.fullDomain ??
|
||||
""
|
||||
}
|
||||
placeholder={
|
||||
selectedResource
|
||||
? t(
|
||||
"redirectResourceNoDomain"
|
||||
)
|
||||
: t(
|
||||
"resourceSelect"
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
|
||||
{/* The cloud only serves HTTPS, so there is nothing to toggle there. */}
|
||||
{build !== "saas" && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="redirect-ssl"
|
||||
label={t(
|
||||
"proxyEnableSSL"
|
||||
)}
|
||||
description={
|
||||
attachTo ===
|
||||
"resource"
|
||||
? t(
|
||||
"redirectSslInheritedDescription"
|
||||
)
|
||||
: t(
|
||||
"redirectSslDescription"
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
attachTo ===
|
||||
"resource"
|
||||
}
|
||||
checked={
|
||||
attachTo ===
|
||||
"resource"
|
||||
? (selectedResource?.ssl ??
|
||||
true)
|
||||
: field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection className="pb-10">
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("redirectSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("redirectSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...form}>
|
||||
<form action={formAction}>
|
||||
<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="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="matchPath"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("matchPath")}
|
||||
</FormLabel>
|
||||
<PathMatchModal
|
||||
value={{
|
||||
path: field.value,
|
||||
pathMatchType:
|
||||
pathMatchType
|
||||
}}
|
||||
onChange={(
|
||||
config
|
||||
) => {
|
||||
// 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>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rewritePath"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t("rewritePath")}
|
||||
</FormLabel>
|
||||
<PathRewriteModal
|
||||
value={{
|
||||
rewritePath:
|
||||
field.value,
|
||||
rewritePathType:
|
||||
rewritePathType
|
||||
}}
|
||||
onChange={(
|
||||
config
|
||||
) => {
|
||||
field.onChange(
|
||||
config.rewritePath ||
|
||||
null
|
||||
);
|
||||
form.setValue(
|
||||
"rewritePathType",
|
||||
(config.rewritePathType as
|
||||
| "exact"
|
||||
| "prefix"
|
||||
| "regex"
|
||||
| "stripPrefix"
|
||||
| null) ??
|
||||
null
|
||||
);
|
||||
}}
|
||||
trigger={
|
||||
hasRewrite ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
|
||||
>
|
||||
<PathRewriteDisplay
|
||||
value={{
|
||||
rewritePath:
|
||||
field.value,
|
||||
rewritePathType:
|
||||
rewritePathType
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t(
|
||||
"rewritePath"
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"redirectRewritePathDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="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>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{isEditing && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("dangerSection")}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
loading={deleteLoading}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{t("redirectDelete")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/${orgId}/settings/redirects`}>
|
||||
{t("cancel")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="redirect-form"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading}
|
||||
>
|
||||
{isEditing ? t("saveSettings") : t("redirectAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
GlobeIcon,
|
||||
MinusIcon,
|
||||
MoreHorizontal,
|
||||
WaypointsIcon
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
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;
|
||||
enabled: boolean;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceFullDomain: string | null;
|
||||
resourceDomainId: string | null;
|
||||
domainId: string | null;
|
||||
baseDomain: string | null;
|
||||
};
|
||||
|
||||
type RedirectsTableProps = {
|
||||
redirects: RedirectRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
initialCertificates?: GetBatchedCertificateResponse;
|
||||
};
|
||||
|
||||
export default function RedirectsTable({
|
||||
redirects,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount,
|
||||
initialCertificates
|
||||
}: RedirectsTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [rows, setRows] = useState(redirects);
|
||||
const [selected, setSelected] = useState<RedirectRow | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setRows(redirects);
|
||||
}, [redirects]);
|
||||
|
||||
function refreshData() {
|
||||
startTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({ searchParams });
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({ searchParams });
|
||||
}, 300);
|
||||
|
||||
// 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}`);
|
||||
setRows((prev) =>
|
||||
prev.filter((r) => r.redirectId !== row.redirectId)
|
||||
);
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelected(null);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("redirectDeleted")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("redirectErrorDelete"),
|
||||
description: formatAxiosError(e, t("redirectErrorDelete"))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<RedirectRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
header: () => <span className="p-3">{t("identifier")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<code className="text-sm">{row.original.niceId}</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "attachedTo",
|
||||
friendlyName: t("redirectAttachedTo"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("redirectAttachedTo")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
|
||||
if (redirect.resourceId && redirect.resourceNiceId) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
|
||||
>
|
||||
<WaypointsIcon className="size-3 text-muted-foreground" />
|
||||
{redirect.resourceName}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirect.domainId) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/domains/${redirect.domainId}`}
|
||||
>
|
||||
<GlobeIcon className="size-3 text-muted-foreground" />
|
||||
{redirect.baseDomain}
|
||||
<ArrowUpRight className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
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;
|
||||
// The cert lives on whichever domain actually terminates
|
||||
// TLS: the resource's domain when attached to a resource,
|
||||
// otherwise the redirect's own domain.
|
||||
const certDomainId =
|
||||
redirect.resourceDomainId ?? redirect.domainId;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{certDomainId && host ? (
|
||||
<ResourceAccessCertIndicator
|
||||
orgId={orgId}
|
||||
domainId={certDomainId}
|
||||
fullDomain={host}
|
||||
initialCertValue={
|
||||
initialCertificates?.[host]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<code className="text-sm truncate">
|
||||
{host ?? ""}
|
||||
{redirect.matchPath && (
|
||||
<span className="text-muted-foreground">
|
||||
{redirect.pathMatchType === "prefix"
|
||||
? withPrefixGlob(redirect.matchPath)
|
||||
: redirect.matchPath}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
accessorKey: "destinationHost",
|
||||
friendlyName: t("redirectDestination"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("redirectDestination")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const redirect = row.original;
|
||||
return (
|
||||
<code className="text-sm truncate">
|
||||
{redirect.destinationHost}
|
||||
{redirect.rewritePath && (
|
||||
<span className="text-muted-foreground">
|
||||
{redirect.rewritePathType === "prefix"
|
||||
? withPrefixGlob(redirect.rewritePath)
|
||||
: redirect.rewritePath}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "priority",
|
||||
friendlyName: t("priority"),
|
||||
header: () => <span className="p-3">{t("priority")}</span>,
|
||||
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: () => <span className="p-3">{t("redirectType")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{row.original.permanent
|
||||
? t("redirectTypePermanent")
|
||||
: t("redirectTypeTemporary")}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<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, initialCertificates]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
if (!val) {
|
||||
setSelected(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("redirectQuestionRemove")}</p>
|
||||
<p>{t("redirectMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("redirectDeleteConfirm")}
|
||||
onConfirm={async () => deleteRedirect(selected)}
|
||||
string={selected.name}
|
||||
title={t("redirectDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
addButtonText={t("redirectAdd")}
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(`/${orgId}/settings/redirects/create`)
|
||||
)
|
||||
}
|
||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||
tableId="redirects-table"
|
||||
searchPlaceholder={t("redirectsSearch")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
columnVisibility={{
|
||||
attachedTo: false,
|
||||
niceId: false,
|
||||
permanent: 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 formRef = useRef<ComponentRef<"form">>(null);
|
||||
|
||||
async function submitAction(formData: FormData) {
|
||||
const newEnabled = !(formData.get("enabled") === "on");
|
||||
setOptimisticEnabled(newEnabled);
|
||||
await onToggleEnabled(newEnabled, redirect.redirectId);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} ref={formRef}>
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={optimisticEnabled !== redirect.enabled}
|
||||
name="enabled"
|
||||
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user