Skip to content

Commit 0a23cd7

Browse files
authored
Merge pull request #10 from mukezhz/feat/redirection
Feature | Redirection
2 parents cf1ea39 + e3ea9e6 commit 0a23cd7

File tree

11 files changed

+269
-71
lines changed

11 files changed

+269
-71
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Domains" ADD COLUMN "redirectUrl" TEXT;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ model Domains {
2121
port Int
2222
isLocked Boolean @default(false)
2323
enableHttps Boolean @default(true)
24+
redirectUrl String?
2425
createdAt DateTime @default(now())
2526
}
2627

src/app/api/_services/caddy/caddy-templates.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,38 @@ export const getRouteHandlerTemplate = (
105105
]
106106
};
107107
};
108+
109+
export const getRedirectTemplate = (
110+
fromDomain: string,
111+
toDomain: string,
112+
enableHttps = true,
113+
): RouteConfig => {
114+
const protocol = enableHttps ? "https" : "http";
115+
const routeConfig: RouteConfig = {
116+
match: [
117+
{
118+
host: [fromDomain],
119+
},
120+
],
121+
handle: [
122+
{
123+
handler: "subroute",
124+
routes: [
125+
{
126+
handle: [
127+
{
128+
handler: "static_response",
129+
headers: {
130+
Location: [`${protocol}://${toDomain}{http.request.uri}`],
131+
},
132+
status_code: 301,
133+
},
134+
],
135+
},
136+
],
137+
},
138+
],
139+
};
140+
141+
return routeConfig;
142+
};

src/app/api/_services/caddy/template-types.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,20 @@ export type RouteHandlerConfig = {
2828
}
2929

3030
export type HandlerConfig = {
31-
handler: "reverse_proxy";
32-
upstreams: { dial: string }[];
31+
handler: "reverse_proxy" | "static_response";
32+
upstreams?: { dial: string }[];
3333
headers: {
34-
request: {
34+
request?: {
3535
set: {
3636
Host: string[];
3737
"X-Origin-Host": string[];
3838
"X-Origin-IP": string[];
3939
};
4040
};
41+
Location?: string[];
4142
};
42-
transport: {
43+
status_code?: number
44+
transport?: {
4345
protocol: string;
4446
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4547
tls?: Record<string, any>;

src/app/api/domain/add/route.ts

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
loadCaddyConfig,
66
validateIncomingDomain,
77
} from "../../_services/caddy/caddy-service";
8-
import { getRouteTemplate } from "../../_services/caddy/caddy-templates";
8+
import { getRouteTemplate, getRedirectTemplate } from "../../_services/caddy/caddy-templates";
99
import prisma from "../../../../lib/prisma";
1010
import { Prisma } from "@prisma/client";
1111
import { getUserFromHeader, hasPermission } from "../../_services/user/user-service";
@@ -22,7 +22,6 @@ export async function POST(request: NextRequest) {
2222
);
2323
}
2424

25-
// Check if user has permission to add domains (requires proxies:manage or proxies:modify)
2625
if (!hasPermission(user, "proxies:manage") && !hasPermission(user, "proxies:modify")) {
2726
return NextResponse.json(
2827
{ error: "Forbidden - Insufficient permissions" },
@@ -33,46 +32,101 @@ export async function POST(request: NextRequest) {
3332
const reqBody = await request.json();
3433
const reqPayload = addDomainSchema.parse(reqBody);
3534

35+
// Check if the domain is already registered
3636
const { currentConfig, hasExistingRoute } = await validateIncomingDomain(
37-
reqPayload.incomingAddress
37+
reqPayload.domain
3838
);
39-
39+
4040
if (hasExistingRoute) {
4141
return NextResponse.json(
42-
{ error: "Domain already registered" },
42+
{ error: `Domain ${reqPayload.domain} is already registered` },
4343
{ status: 409 }
4444
);
4545
}
4646

47-
const parsedPort = Number(reqPayload.port);
48-
49-
const routeConfig = getRouteTemplate(
50-
reqPayload.incomingAddress,
51-
reqPayload.destinationAddress,
52-
parsedPort,
53-
reqPayload.enableHttps
54-
);
47+
if (!currentConfig) {
48+
return NextResponse.json(
49+
{ error: "Failed to retrieve Caddy config" },
50+
{ status: 500 }
51+
);
52+
}
5553

54+
const parsedPort = reqPayload.port === "" ? null : Number(reqPayload.port);
5655
const newConfigPayload = { ...currentConfig };
57-
newConfigPayload.apps.http.servers.main.routes.push(routeConfig);
58-
59-
56+
6057
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
58+
// Create a new Caddy configuration
6159
await tx.caddyConfiguration.create({
6260
data: {
6361
config: JSON.parse(JSON.stringify(newConfigPayload)),
6462
},
6563
});
66-
await tx.domains.create({
67-
data: {
68-
incomingAddress: reqPayload.incomingAddress,
69-
destinationAddress: reqPayload.destinationAddress,
70-
port: parsedPort,
71-
enableHttps: reqPayload.enableHttps
72-
},
64+
65+
// Check if domain already exists in database
66+
const existingDomain = await tx.domains.findUnique({
67+
where: { incomingAddress: reqPayload.domain }
7368
});
69+
70+
if (existingDomain) {
71+
console.log(`Domain ${reqPayload.domain} already exists in database, updating...`);
72+
73+
// Update the existing domain
74+
await tx.domains.update({
75+
where: { incomingAddress: reqPayload.domain },
76+
data: {
77+
destinationAddress: reqPayload.enableRedirection && reqPayload.redirectTo ?
78+
reqPayload.redirectTo.trim() : reqPayload.destinationAddress,
79+
port: parsedPort ?? undefined,
80+
enableHttps: reqPayload.enableHttps,
81+
redirectUrl: reqPayload.enableRedirection && reqPayload.redirectTo ?
82+
reqPayload.redirectTo.trim() : null,
83+
}
84+
});
85+
} else {
86+
// Add new domain configuration based on whether redirection is enabled
87+
if (reqPayload.enableRedirection && reqPayload.redirectTo && reqPayload.redirectTo.trim()) {
88+
const redirectConfig = getRedirectTemplate(
89+
reqPayload.domain,
90+
reqPayload.redirectTo,
91+
reqPayload.enableHttps
92+
);
93+
newConfigPayload.apps.http.servers.main.routes.push(redirectConfig);
94+
95+
// Save domain in database with redirection info
96+
await tx.domains.create({
97+
data: {
98+
incomingAddress: reqPayload.domain,
99+
destinationAddress: reqPayload.redirectTo.trim(),
100+
port: parsedPort || 0,
101+
enableHttps: reqPayload.enableHttps,
102+
redirectUrl: reqPayload.redirectTo.trim() // Store redirection info
103+
}
104+
});
105+
} else {
106+
// Create a normal proxy route
107+
const routeConfig = getRouteTemplate(
108+
reqPayload.domain,
109+
reqPayload.destinationAddress,
110+
parsedPort ?? 80,
111+
reqPayload.enableHttps
112+
);
113+
newConfigPayload.apps.http.servers.main.routes.push(routeConfig);
114+
115+
// Save domain in database without redirection info
116+
await tx.domains.create({
117+
data: {
118+
incomingAddress: reqPayload.domain,
119+
destinationAddress: reqPayload.destinationAddress,
120+
port: parsedPort || 0,
121+
enableHttps: reqPayload.enableHttps,
122+
redirectUrl: null // No redirection
123+
}
124+
});
125+
}
126+
}
74127
});
75128

129+
console.log("New Caddy configuration updated");
76130
await loadCaddyConfig(newConfigPayload);
77131

78132
return NextResponse.json(
@@ -81,16 +135,17 @@ export async function POST(request: NextRequest) {
81135
},
82136
{ status: 201 }
83137
);
84-
} catch (err) {
85-
if (err instanceof z.ZodError) {
138+
} catch (error) {
139+
if (error instanceof z.ZodError) {
86140
return NextResponse.json(
87141
{
88142
error: "Validation Failed",
89-
details: err.errors,
143+
details: error.errors,
90144
},
91145
{ status: 400 }
92146
);
93147
}
148+
console.error("error...", error)
94149
return NextResponse.json(
95150
{ error: "Failed to add domain" },
96151
{ status: 500 }

src/app/api/domain/domain-schema.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,61 @@
11
import { z } from "zod";
22

3-
const domainRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
3+
// Updated regex to accept localhost and other local development domains
4+
const domainRegex = /^[a-zA-Z0-9.-]+\.[a-zA-Z0-9]{1,}$/;
45

56
const domainOrIpOrDockerRegex =
6-
/^(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}|\b\d{1,3}(\.\d{1,3}){3}\b|\[?[a-fA-F0-9:]+\]?|[a-zA-Z0-9_-]+)$/;
7+
/^(?:[a-zA-Z0-9.-]+\.[a-zA-Z0-9]{1,}|\b\d{1,3}(\.\d{1,3}){3}\b|\[?[a-fA-F0-9:]+\]?|[a-zA-Z0-9_-]+)$/;
78

89
export const addDomainSchema = z.object({
9-
incomingAddress: z
10+
domain: z
1011
.string()
11-
.min(1, "Incoming address is required")
12+
.min(1, "Domain is required")
1213
.refine((value) => domainRegex.test(value), {
1314
message:
1415
"Invalid domain format (must be a plain domain, e.g., example.com)",
1516
}),
17+
enableRedirection: z.boolean().default(false),
18+
redirectTo: z.string().optional(),
1619
destinationAddress: z
17-
.string()
18-
.min(1, "Destination address is required")
19-
.refine((value) => domainOrIpOrDockerRegex.test(value), {
20-
message:
21-
"Invalid address format (must be a domain, IP, service name etc.)",
22-
}),
23-
port: z.string().min(1, "Port is required"),
20+
.string(),
21+
port: z.string(),
2422
enableHttps: z.boolean().default(true),
23+
}).superRefine((data, ctx) => {
24+
const issues = [];
25+
26+
if (data.enableRedirection && data.redirectTo) {
27+
if (!domainRegex.test(data.redirectTo.trim())) {
28+
ctx.addIssue({
29+
path: ["redirectTo"],
30+
message: "Invalid redirect domain format",
31+
code: z.ZodIssueCode.custom,
32+
});
33+
issues.push("redirectTo");
34+
}
35+
}
36+
37+
if (!data.enableRedirection) {
38+
const portNumber = parseInt(data.port, 10);
39+
if (isNaN(portNumber) || portNumber < 1 || portNumber > 65535) {
40+
ctx.addIssue({
41+
path: ["port"],
42+
message: "Invalid port number",
43+
code: z.ZodIssueCode.custom,
44+
});
45+
issues.push("port");
46+
}
47+
48+
if (!domainOrIpOrDockerRegex.test(data.destinationAddress)) {
49+
ctx.addIssue({
50+
path: ["destinationAddress"],
51+
message: "Invalid destination address format",
52+
code: z.ZodIssueCode.custom,
53+
});
54+
issues.push("destinationAddress");
55+
}
56+
}
57+
58+
return issues.length === 0;
2559
});
2660

2761
export type AddDomainValues = z.infer<typeof addDomainSchema>

src/app/api/domain/domain-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export type DomainWithCheckResults = {
88
createdAt: Date;
99
isLocked: boolean;
1010
enableHttps: boolean;
11+
redirectUrl?: string;
1112
checkResults: DomainCheckResults;
1213
};

src/app/api/domain/registered/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export async function GET(request: NextRequest) {
3939
const domainCheckResults = await checkDomain(domain.incomingAddress);
4040
domainsWithCheckResults.push({
4141
...domain,
42+
redirectUrl: domain?.redirectUrl || undefined,
4243
checkResults: domainCheckResults,
4344
});
4445
}

0 commit comments

Comments
 (0)