feat: support multi domain configuration for DNS records

This commit is contained in:
oiov
2025-05-19 21:40:24 +08:00
parent 7d83ff8678
commit 87c24da5a0
25 changed files with 658 additions and 232 deletions
+2 -3
View File
@@ -28,11 +28,10 @@ RESEND_API_KEY=
# -----------------------------------------------------------------------------
# Cloudflare
# -----------------------------------------------------------------------------
CLOUDFLARE_ZONE_ID=
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=wr.do,uv.do
CLOUDFLARE_ZONE=[{"zone_id":"abc123", "zone_name": "wr.do"},{"zone_id":"abc456", "zone_name": "uv.do"}]
CLOUDFLARE_API_KEY=
CLOUDFLARE_EMAIL=
# Cloudflare zone name, example: wr.do
CLOUDFLARE_ZONE_NAME=
# Open Signup
NEXT_PUBLIC_OPEN_SIGNUP=1
+6 -1
View File
@@ -25,7 +25,12 @@ export default async function DashboardPage() {
linkText="DNS records."
/>
<UserRecordsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
action="/api/record/admin"
/>
</>
+3 -1
View File
@@ -119,7 +119,7 @@ async function UserUrlsListSection({
async function UserRecordsListSection({
user,
}: {
user: { id: string; name: string; apiKey: string };
user: { id: string; name: string; apiKey: string; email: string };
}) {
return (
<UserRecordsList
@@ -127,6 +127,7 @@ async function UserRecordsListSection({
id: user.id,
name: user.name,
apiKey: user.apiKey,
email: user.email,
}}
action="/api/record"
/>
@@ -208,6 +209,7 @@ export default async function DashboardPage() {
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
/>
</Suspense>
+6 -1
View File
@@ -25,7 +25,12 @@ export default async function DashboardPage() {
linkText="DNS records."
/>
<UserRecordsList
user={{ id: user.id, name: user.name || "", apiKey: user.apiKey || "" }}
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
}}
action="/api/record"
/>
</>
@@ -43,7 +43,7 @@ import { LinkPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
export interface RecordListProps {
user: Pick<User, "id" | "name" | "apiKey">;
user: Pick<User, "id" | "name" | "apiKey" | "email">;
action: string;
}
@@ -344,7 +344,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowModal={setShowForm}
>
<RecordForm
user={{ id: user.id, name: user.name || "" }}
user={{ id: user.id, name: user.name || "", email: user.email || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
+28 -21
View File
@@ -9,33 +9,23 @@ import {
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret } from "@/lib/utils";
import { generateSecret, parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
status: 400,
statusText: "API key、zone iD and email are required",
});
}
// Check quota: 若是管理员则不检查,否则检查
const { total } = await getUserRecordCount(user.id);
if (
user.role !== "ADMIN" &&
@@ -50,13 +40,30 @@ export async function POST(req: Request) {
const record = {
...records[0],
id: generateSecret(16),
// type: "CNAME",
proxied: false,
};
const record_name = record.name.endsWith(".wr.do")
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: record.name + ".wr.do";
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
@@ -78,7 +85,7 @@ export async function POST(req: Request) {
}
const data = await createDNSRecord(
CLOUDFLARE_ZONE_ID,
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
record,
@@ -92,8 +99,8 @@ export async function POST(req: Request) {
} else {
const res = await createUserRecord(user.id, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_NAME,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
+141
View File
@@ -0,0 +1,141 @@
import { env } from "@/env.mjs";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
createUserRecord,
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret, parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Admin access required",
});
}
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
status: 400,
statusText: "API key、zone iD and email are required",
});
}
const { records, email } = await req.json();
const target_user = await getUserByEmail(email);
if (!target_user) {
return Response.json("User not found", {
status: 404,
statusText: "User not found",
});
}
const { total } = await getUserRecordCount(target_user.id);
if (total >= TeamPlanQuota[target_user.team!].RC_NewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
}
const record = {
...records[0],
id: generateSecret(16),
};
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
status: 403,
});
}
const user_record = await getUserRecordByTypeNameContent(
target_user.id,
record.type,
record_name,
record.content,
1,
);
if (user_record && user_record.length > 0) {
return Response.json("Record already exists", {
status: 403,
});
}
const data = await createDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
record,
);
if (!data.success || !data.result?.id) {
// console.log("[data]", data);
return Response.json(data.messages, {
status: 501,
});
} else {
const res = await createUserRecord(target_user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
proxied: data.result.proxied,
proxiable: data.result.proxiable,
ttl: data.result.ttl,
comment: data.result.comment ?? "",
tags: data.result.tags?.join("") ?? "",
created_on: data.result.created_on,
modified_on: data.result.modified_on,
active: 0,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
}
return Response.json(res.data);
}
} catch (error) {
console.error("[错误]", error);
return Response.json(error, {
status: error?.status || 500,
});
}
}
+34 -20
View File
@@ -3,6 +3,7 @@ import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -11,44 +12,57 @@ export async function POST(req: Request) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Admin access required",
});
}
const { record_id, zone_id, userId, active } = await req.json();
if (!record_id || !userId) {
return Response.json("RecordId and userId are required", {
if (!record_id || !userId || !zone_id) {
return Response.json("record_id, userId, and zone_id are required", {
status: 400,
statusText: "Invalid request body",
});
}
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "Missing required configuration",
},
);
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
// Delete cf dns record first.
const res = await deleteDNSRecord(
CLOUDFLARE_ZONE_ID,
// force delete
await deleteUserRecord(userId, record_id, zone_id, active);
await deleteDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
record_id,
);
if (res && res.result?.id) {
// Then delete user record.
await deleteUserRecord(userId, record_id, zone_id, active);
return Response.json("success", {
status: 200,
});
}
return Response.json("Not Implemented", {
status: 501,
return Response.json("success", {
status: 200,
statusText: "success",
});
} catch (error) {
console.error(error);
return Response.json(error?.statusText || error, {
status: error.status || 500,
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
+80 -45
View File
@@ -3,6 +3,7 @@ import { updateDNSRecord } from "@/lib/cloudflare";
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -11,70 +12,104 @@ export async function POST(req: Request) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Admin access required",
});
}
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
return Response.json("API key、zone iD and email are required", {
status: 400,
});
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "Missing required configuration",
},
);
}
const { record, recordId, userId } = await req.json();
if (!recordId || !userId) {
return Response.json("RecordId and userId are required", {
if (!record || !recordId || !userId) {
return Response.json("record, recordId, and userId are required", {
status: 400,
statusText: "Invalid request body",
});
}
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
const data = await updateDNSRecord(
CLOUDFLARE_ZONE_ID,
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
recordId,
record,
{ ...record, name: record_name },
);
if (!data.success || !data.result?.id) {
return Response.json(data.errors, {
status: 501,
});
} else {
const res = await updateUserRecord(userId, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_NAME,
name: data.result.name,
type: data.result.type,
content: data.result.content,
proxied: data.result.proxied,
proxiable: data.result.proxiable,
ttl: data.result.ttl,
comment: data.result.comment ?? "",
tags: data.result.tags?.join("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
}
return Response.json(res.data);
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
{
status: 501,
statusText: "Cloudflare API error",
},
);
}
const res = await updateUserRecord(userId, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
proxied: data.result.proxied,
proxiable: data.result.proxiable,
ttl: data.result.ttl,
comment: data.result.comment ?? "",
tags: data.result.tags?.join("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
statusText: "Failed to update user record",
});
}
return Response.json(res.data, {
status: 200,
statusText: "success",
});
} catch (error) {
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}
+24 -9
View File
@@ -3,6 +3,7 @@ import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -10,36 +11,50 @@ export async function POST(req: Request) {
if (user instanceof Response) return user;
const { record_id, zone_id, active } = await req.json();
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "API key, zone configuration, and email are required",
},
);
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
// Delete cf dns record first.
const res = await deleteDNSRecord(
CLOUDFLARE_ZONE_ID,
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
record_id,
);
if (res && res.result?.id) {
// Then delete user record.
await deleteUserRecord(user.id, record_id, zone_id, active);
return Response.json("success", {
status: 200,
statusText: "success",
});
}
return Response.json({
status: 501,
statusText: "Not Implemented",
statusText: "Failed to delete DNS record",
});
} catch (error) {
console.error(error);
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error.status || 500,
statusText: error.statusText || "Server error",
});
+118 -57
View File
@@ -7,105 +7,155 @@ import {
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
// update record
// Update DNS record
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const {
CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
} = env;
if (
!CLOUDFLARE_ZONE_ID ||
!CLOUDFLARE_ZONE_NAME ||
!CLOUDFLARE_API_KEY ||
!CLOUDFLARE_EMAIL
) {
return Response.json("API key andzone id are required.", { status: 401 });
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
);
}
const { record, recordId } = await req.json();
if (!record || !recordId) {
return Response.json("Record and recordId are required", {
status: 400,
statusText: "Invalid request body",
});
}
const record_name = record.name.endsWith(".wr.do")
let record_name = ["A", "CNAME"].includes(record.type)
? record.name
: record.name + ".wr.do";
: `${record.name}.${record.zone_name}`;
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
matchedZone = zone;
break;
}
}
if (!matchedZone) {
return Response.json(
`No matching zone found for domain: ${record_name}`,
{
status: 400,
statusText: "Invalid domain",
},
);
}
if (reservedDomains.includes(record_name)) {
return Response.json("Domain name is reserved", {
status: 403,
statusText: "Reserved domain",
});
}
const data = await updateDNSRecord(
CLOUDFLARE_ZONE_ID,
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
recordId,
record,
{ ...record, name: record_name },
);
console.log("updateDNSRecord", data);
console.log("[updateDNSRecord]", data);
if (!data.success || !data.result?.id) {
return Response.json(data.errors, {
status: 501,
});
} else {
const res = await updateUserRecord(user.id, {
record_id: data.result.id,
zone_id: CLOUDFLARE_ZONE_ID,
zone_name: CLOUDFLARE_ZONE_NAME,
name: data.result.name,
type: data.result.type,
content: data.result.content,
proxied: data.result.proxied,
proxiable: data.result.proxiable,
ttl: data.result.ttl,
comment: data.result.comment ?? "",
tags: data.result.tags?.join("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
});
}
return Response.json(res.data);
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
{ status: 501, statusText: "Cloudflare API error" },
);
}
const res = await updateUserRecord(user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id, // Use matched zone_id
zone_name: matchedZone.zone_name, // Use matched zone_name
name: data.result.name,
type: data.result.type,
content: data.result.content,
proxied: data.result.proxied,
proxiable: data.result.proxiable,
ttl: data.result.ttl,
comment: data.result.comment ?? "",
tags: data.result.tags?.join("") ?? "",
modified_on: data.result.modified_on,
active: 1,
});
if (res.status !== "success") {
return Response.json(res.status, {
status: 502,
statusText: "Failed to update user record",
});
}
return Response.json(res.data);
} catch (error) {
console.log(error);
return Response.json(error?.statusText || error, {
console.error("[Error]", error);
return Response.json(error.message || "Server error", {
status: error?.status || 500,
statusText: error?.statusText || "Server error",
});
}
}
// update record state
// Update record state
export async function PUT(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key and zone id are required.", {
status: 401,
});
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
);
}
const { zone_id, record_id, target, active } = await req.json();
if (!zone_id || !record_id || !target) {
return Response.json("zone_id, record_id, and target are required", {
status: 400,
statusText: "Invalid request body",
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
statusText: "Invalid zone_id",
});
}
let isTargetAccessible = false;
try {
const target_res = await fetch(`https://${target}`);
const target_res = await fetch(`https://${target}`, {
method: "HEAD",
signal: AbortSignal.timeout(10000),
});
isTargetAccessible = target_res.status === 200;
} catch (fetchError) {
isTargetAccessible = false;
// console.log(`Failed to access target: ${fetchError}`);
console.log(
`[Fetch Error] Failed to access target ${target}: ${fetchError}`,
);
}
const res = await updateUserRecordState(
@@ -116,13 +166,24 @@ export async function PUT(req: Request) {
);
if (!res) {
return Response.json("An error occurred.", { status: 502 });
return Response.json("Failed to update record state", {
status: 502,
statusText: "Database error",
});
}
return Response.json(
isTargetAccessible ? "Target is accessible!" : "Target is unaccessible!",
{ status: 200 },
);
} catch (error) {
console.error(error);
return Response.json(`An error occurred. ${error}`, { status: 500 });
console.error("[Error]", error);
return Response.json(
`An error occurred: ${error.message || "Unknown error"}`,
{
status: 500,
statusText: "Server error",
},
);
}
}
+2 -2
View File
@@ -32,8 +32,8 @@ export default async function Dashboard({ children }: ProtectedLayoutProps) {
<DashboardSidebar links={filteredLinks} />
<div className="flex flex-1 flex-col">
<header className="sticky top-0 z-50 flex h-14 border-b bg-background px-4 lg:h-[60px] xl:px-8">
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
<header className="sticky top-0 z-50 flex h-14 border-b bg-background px-4 lg:h-[60px]">
<MaxWidthWrapper className="flex max-w-full items-center gap-x-3 px-0">
<MobileSheetSidebar links={filteredLinks} />
<div className="w-full flex-1">
+106 -43
View File
@@ -6,6 +6,7 @@ import { User } from "@prisma/client";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { CreateDNSRecord, RecordType } from "@/lib/cloudflare";
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
import { RECORD_TYPE_ENUMS, TTL_ENUMS } from "@/lib/enums";
@@ -30,7 +31,7 @@ export type FormData = CreateDNSRecord;
export type FormType = "add" | "edit";
export interface RecordFormProps {
user: Pick<User, "id" | "name">;
user: Pick<User, "id" | "name" | "email">;
isShowForm: boolean;
setShowForm: Dispatch<SetStateAction<boolean>>;
type: FormType;
@@ -53,24 +54,25 @@ export function RecordForm({
const [currentRecordType, setCurrentRecordType] = useState(
initData?.type || "CNAME",
);
const [currentZoneName, setCurrentZoneName] = useState(
initData?.zone_name || "wr.do",
);
const [email, setEmail] = useState(user.email);
const {
handleSubmit,
register,
formState: { errors },
getValues,
setValue,
} = useForm<FormData>({
resolver: zodResolver(createRecordSchema),
defaultValues: {
zone_name: initData?.zone_name || "wr.do",
type: initData?.type || "CNAME",
ttl: initData?.ttl || 1,
proxied: initData?.proxied || false,
comment: initData?.comment || "",
name:
(initData?.name.endsWith(".wr.do")
? initData?.name.slice(0, -6)
: initData?.name) || "",
comment: "Created by wr.do",
name: initData?.name ? initData.name.split(".")[0] : "",
content: initData?.content || "",
},
});
@@ -89,6 +91,7 @@ export function RecordForm({
method: "POST",
body: JSON.stringify({
records: [data],
email,
}),
});
@@ -161,7 +164,62 @@ export function RecordForm({
{type === "add" ? "Create" : "Edit"} record
</div>
<form className="p-4" onSubmit={onSubmit}>
{action.indexOf("admin") > -1 && (
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns required title="User email">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="content">
User email
</Label>
<Input
id="email"
className="flex-1 shadow-inner"
size={32}
defaultValue={email || ""}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.content ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.content.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. Enter user email
</p>
)}
</div>
</FormSectionColumns>
</div>
)}
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Domain" required>
<Select
onValueChange={(value: string) => {
setValue("zone_name", value);
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.recordDomains.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
</p>
</FormSectionColumns>
<FormSectionColumns title="Type" required>
<Select
onValueChange={(value: RecordType) => {
@@ -184,6 +242,9 @@ export function RecordForm({
</Select>
<p className="p-1 text-[13px] text-muted-foreground">Required.</p>
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Name" required>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
@@ -196,12 +257,12 @@ export function RecordForm({
size={32}
{...register("name")}
/>
{currentRecordType === "CNAME" ||
(currentRecordType === "A" && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-slate-500">
.wr.do
</span>
))}
{(currentRecordType === "CNAME" ||
currentRecordType === "A") && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-slate-500">
.{currentZoneName}
</span>
)}
</div>
</div>
<div className="flex flex-col justify-between p-1">
@@ -211,38 +272,13 @@ export function RecordForm({
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. Use @ for root.
Required. E.g. www.
</p>
)}
</div>
</FormSectionColumns>
</div>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="TTL" required>
<Select
onValueChange={(value: string) => {
setValue("ttl", Number(value));
}}
name="ttl"
defaultValue={String(initData?.ttl || 1)}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{TTL_ENUMS.map((ttl) => (
<SelectItem key={ttl.value} value={ttl.value}>
{ttl.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Optional. Time To Live.
</p>
</FormSectionColumns>
<FormSectionColumns
required
title={
currentRecordType === "CNAME"
? "Content"
@@ -281,7 +317,7 @@ export function RecordForm({
</div>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Comment">
{/* <FormSectionColumns title="Comment">
<div className="flex items-center gap-2">
<Label className="sr-only" htmlFor="comment">
Comment
@@ -296,16 +332,43 @@ export function RecordForm({
<p className="p-1 text-[13px] text-muted-foreground">
Enter your comment here (up to 100 characters)
</p>
</FormSectionColumns> */}
<FormSectionColumns title="TTL" required>
<Select
onValueChange={(value: string) => {
setValue("ttl", Number(value));
}}
name="ttl"
defaultValue={String(initData?.ttl || 1)}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{TTL_ENUMS.map((ttl) => (
<SelectItem key={ttl.value} value={ttl.value}>
{ttl.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Optional. Time To Live.
</p>
</FormSectionColumns>
<FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch id="proxied" {...register("proxied")} />
<Switch
id="proxied"
{...register("proxied")}
onCheckedChange={(value) => setValue("proxied", value)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status
Proxy status.
</p>
</FormSectionColumns>
</div>
+2
View File
@@ -6,6 +6,7 @@ const open_signup = env.NEXT_PUBLIC_OPEN_SIGNUP;
const short_domains = env.NEXT_PUBLIC_SHORT_DOMAINS || "";
const email_domains = env.NEXT_PUBLIC_EMAIL_DOMAINS || "";
const email_r2_domain = env.NEXT_PUBLIC_EMAIL_R2_DOMAIN || "";
const record_domains = env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME || "";
export const siteConfig: SiteConfig = {
name: "WR.DO",
@@ -24,6 +25,7 @@ export const siteConfig: SiteConfig = {
openSignup: open_signup === "1" ? true : false,
shortDomains: short_domains.split(","),
emailDomains: email_domains.split(","),
recordDomains: record_domains.split(","),
emailR2Domain: email_r2_domain,
};
+39 -11
View File
@@ -8,23 +8,51 @@ Before you start, you must have a Cloudflare account and be hosted on Cloudflare
In this section, you can update these variables:
```js title=".env"
CLOUDFLARE_ZONE_ID=abcdef1234567890
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
CLOUDFLARE_EMAIL=user@example.com
CLOUDFLARE_ZONE_NAME=wr.do
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
```
### Variable Descriptions
- **CLOUDFLARE_ZONE_ID**: This is the unique identifier for your Cloudflare zone. You can find it in the Cloudflare dashboard under the Overview section of your domain.
## Variable Descriptions
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
- **CLOUDFLARE_API_KEY**: This is the API key that you use to authenticate requests to the Cloudflare API. You can generate or find your API key in the Cloudflare dashboard under the `profile` -> `api-tokens` section.
### CLOUDFLARE_ZONE
> Follow [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens), and scroll down to `API Token`, the `Global API Key` should be used.
- **CLOUDFLARE_EMAIL**: This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
- Description: A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
- Where to find: In the Cloudflare dashboard, go to your domains Overview section and locate the Zone ID.
- Example: `[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"def789","zone_name":"example2.com"}]`
- Note: Ensure the zone_name values match the domains listed in NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME.
- **CLOUDFLARE_ZONE_NAME**: This is the name of your Cloudflare zone. It is used to specify the zone in the Cloudflare API requests.
> Instructions: Navigate to Cloudflare Dashboard, select your account, and find the Zone ID under the Overview tab of your domain.
### CLOUDFLARE_API_KEY
- Description: The API key used to authenticate requests to the Cloudflare API.
- Where to find: In the Cloudflare dashboard, go to Profile > API Tokens and locate your Global API Key.
- Example: 1234567890abcdef1234567890abcdef
- Security Note: Keep this key confidential and never expose it in client-side code.
> Instructions: Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
### CLOUDFLARE_EMAIL
- Description: The email address associated with your Cloudflare account, used for API authentication alongside the API key.
- Example: `user@example.com`
### NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME
- Description: A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
- Example: example.com,example2.com
- Note: Since this variable is prefixed with NEXT_PUBLIC_, it is exposed to the frontend. Ensure it only contains domain names and no sensitive information.
### Important Notes
- Correspondence: The zone_name in `CLOUDFLARE_ZONE` must match the domains listed in NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME. For example, if CLOUDFLARE_ZONE includes `{"zone_id":"abc465","zone_name":"example.com"}`, then `NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME` should include example.com.
- Security: Never expose `CLOUDFLARE_API_KEY` or `CLOUDFLARE_ZONE` in frontend code, as they contain sensitive information. Only `NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME` is safe for frontend use.
- Validation: Ensure the zone_id and zone_name in `CLOUDFLARE_ZONE` are correct, as incorrect values will cause API requests to fail.
### Troubleshooting
- API Key Issues: If API requests fail, verify your `CLOUDFLARE_API_KEY` and `CLOUDFLARE_EMAIL` are correct and have the necessary permissions.
- Zone Mismatch: If the frontend displays incorrect domains, ensure `NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME` matches the zone_name values in `CLOUDFLARE_ZONE`.
- Finding Zone ID: If you cant locate your Zone ID, check the Overview tab of your domain in the Cloudflare dashboard.
+3 -2
View File
@@ -48,7 +48,8 @@ Copy/paste the `.env.example` in the `.env` file:
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
| RESEND_API_KEY | `123465` | The API key for Resend. |
| CLOUDFLARE_ZONE_ID | `123465` | The zone ID for Cloudflare. |
| CLOUDFLARE_ZONE | `[{"zone_id":"abc465","zone_name":"example.com"}]` | The zone info for Cloudflare. |
| NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME | `example.com,example2.com` | The zone name for Cloudflare. |
| CLOUDFLARE_API_KEY | `123465` | The API key for Cloudflare. |
| CLOUDFLARE_EMAIL | `123465` | The email for Cloudflare. |
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
@@ -61,7 +62,7 @@ Copy/paste the `.env.example` in the `.env` file:
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
- How to get `CLOUDFLARE_ZONE_ID`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`, see [Cloudflare Configs](/docs/developer/cloudflare).
- How to get `CLOUDFLARE_ZONE`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`、`NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME`, see [Cloudflare Configs](/docs/developer/cloudflare).
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
+27 -8
View File
@@ -140,9 +140,9 @@ RESEND_API_KEY = re_your_resend_api_key;
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
### Add the CLOUDFLARE_ZONE_ID Environment Variable
### Add the CLOUDFLARE_ZONE Environment Variable
This is the unique identifier for your Cloudflare zone. You can find it in the Cloudflare dashboard under the Overview section of your domain.
A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
@@ -156,15 +156,15 @@ This is the unique identifier for your Cloudflare zone. You can find it in the C
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
### Add the CLOUDFLARE_ZONE_NAME Environment Variable
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
This is the name of your Cloudflare zone. It is used to specify the zone in the Cloudflare API requests.
A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
In this section, you can update these variables:
```js title=".env"
CLOUDFLARE_ZONE_ID=abcdef1234567890
CLOUDFLARE_ZONE_NAME=wr.do
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
CLOUDFLARE_EMAIL=user@example.com
```
@@ -228,7 +228,7 @@ Via [http://localhost:3000](http://localhost:3000)
## Q & A
### 1. Worker Error - Too many redirects
### Worker Error - Too many redirects
Via:
@@ -236,4 +236,23 @@ Via:
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
```
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
### How can I access the admin panel after first deployment?
You need to first register an account and log in,
and modify the `role` field of this account to `ADMIN` in the `users` table of the database.
Then, refresh the website and access http://localhost:3000/admin.
<Callout type="note">
Although it may not be convenient to do so, this is currently the fastest way to become an administrator.
In future versions, we will implement the function of automatically setting up administrators as soon as possible.
</Callout>
### How can I change the team plan quota?
Via team.ts:
```bash
https://github.com/oiov/wr.do/tree/main/config/team.ts
```
+5 -4
View File
@@ -15,8 +15,7 @@ export const env = createEnv({
LinuxDo_CLIENT_SECRET: z.string().optional(),
DATABASE_URL: z.string().min(1),
RESEND_API_KEY: z.string().optional(),
CLOUDFLARE_ZONE_ID: z.string().min(1),
CLOUDFLARE_ZONE_NAME: z.string().min(1),
CLOUDFLARE_ZONE: z.string().min(1),
CLOUDFLARE_API_KEY: z.string().min(1),
CLOUDFLARE_EMAIL: z.string().min(1),
SCREENSHOTONE_BASE_URL: z.string().optional(),
@@ -28,6 +27,7 @@ export const env = createEnv({
NEXT_PUBLIC_SHORT_DOMAINS: z.string().min(1).default(""),
NEXT_PUBLIC_EMAIL_DOMAINS: z.string().min(1).default(""),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().min(1),
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME: z.string().min(1),
},
runtimeEnv: {
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
@@ -43,8 +43,9 @@ export const env = createEnv({
NEXT_PUBLIC_SHORT_DOMAINS: process.env.NEXT_PUBLIC_SHORT_DOMAINS,
NEXT_PUBLIC_EMAIL_DOMAINS: process.env.NEXT_PUBLIC_EMAIL_DOMAINS,
NEXT_PUBLIC_EMAIL_R2_DOMAIN: process.env.NEXT_PUBLIC_EMAIL_R2_DOMAIN,
CLOUDFLARE_ZONE_ID: process.env.CLOUDFLARE_ZONE_ID,
CLOUDFLARE_ZONE_NAME: process.env.CLOUDFLARE_ZONE_NAME,
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME:
process.env.NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME,
CLOUDFLARE_ZONE: process.env.CLOUDFLARE_ZONE,
CLOUDFLARE_API_KEY: process.env.CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL: process.env.CLOUDFLARE_EMAIL,
SCREENSHOTONE_BASE_URL: process.env.SCREENSHOTONE_BASE_URL,
+1
View File
@@ -2,6 +2,7 @@ export const CLOUDFLARE_API_URL = "https://api.cloudflare.com/client/v4";
export interface CreateDNSRecord {
id: string;
zone_name: string;
type: string;
name: string;
content: string;
+3
View File
@@ -12,8 +12,11 @@ export const getUserByEmail = async (email: string) => {
email: email,
},
select: {
id: true,
name: true,
emailVerified: true,
active: true,
team: true,
},
});
+22
View File
@@ -388,3 +388,25 @@ export function extractHost(url: string): string {
const match = url.match(regex);
return match ? match[1] : "";
}
// 解析 CLOUDFLARE_ZONE 环境变量并返回结构化的域名配置
export function parseZones(raw: string) {
let zones;
try {
zones = JSON.parse(raw);
} catch (error) {
return [];
}
if (!Array.isArray(zones)) {
return [];
}
const parsedZones = zones.map((zone) => {
const { zone_id, zone_name } = zone;
return { zone_id, zone_name };
});
return parsedZones;
}
+1
View File
@@ -1,6 +1,7 @@
import * as z from "zod";
export const createRecordSchema = z.object({
zone_name: z.string().min(1).max(32),
type: z
.string()
.regex(/^[a-zA-Z0-9]+$/, "Invalid characters")
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -19,6 +19,7 @@ export type SiteConfig = {
openSignup: boolean;
shortDomains: string[];
emailDomains: string[];
recordDomains: string[];
emailR2Domain: string;
};