feat: support multi domain configuration and management features (database)

This commit is contained in:
oiov
2025-05-20 22:46:13 +08:00
parent 28c0cf7da7
commit 8a05fa0907
51 changed files with 4158 additions and 368 deletions
-10
View File
@@ -25,14 +25,6 @@ DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=requ
# -----------------------------------------------------------------------------
RESEND_API_KEY=
# -----------------------------------------------------------------------------
# Cloudflare
# -----------------------------------------------------------------------------
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=
# Open Signup
NEXT_PUBLIC_OPEN_SIGNUP=1
@@ -45,7 +37,5 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
# GitHub api token for getting gitHub stars count
GITHUB_TOKEN=
# Short domains, split by ","
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
@@ -0,0 +1,364 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Domain, User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import {
cn,
expirationTime,
fetcher,
removeUrlSuffix,
timeAgo,
} from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DomainForm } from "@/components/forms/domain-form";
import { FormType } from "@/components/forms/record-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import { PaginationWrapper } from "@/components/shared/pagination";
export interface DomainListProps {
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
action: string;
}
function TableColumnSekleton() {
return (
<TableRow className="grid grid-cols-7 items-center">
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-32" />
</TableCell>
</TableRow>
);
}
export default function DomainList({ user, action }: DomainListProps) {
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditDomain, setCurrentEditDomain] =
useState<DomainFormData | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isShowDomainInfo, setShowDomainInfo] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
null,
);
const [searchParams, setSearchParams] = useState({
slug: "",
target: "",
userName: "",
});
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{
total: number;
list: DomainFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
undefined,
);
};
const handleChangeStatus = async (
checked: boolean,
target: string,
domain: DomainFormData,
) => {
const res = await fetch(action, {
method: "PUT",
body: JSON.stringify({
id: domain.id,
enable_short_link:
target === "enable_short_link" ? checked : domain.enable_short_link,
enable_email: target === "enable_email" ? checked : domain.enable_email,
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
active: target === "active" ? checked : domain.active,
}),
});
if (res.ok) {
const data = await res.json();
if (data) {
toast.success("Successed!");
}
} else {
toast.error("Activation failed!");
}
};
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
<CardDescription className="text-balance text-lg font-bold">
<span>Total Domains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
onClick={() => handleRefresh()}
disabled={isLoading}
>
{isLoading ? (
<RefreshCwIcon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditDomain(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
Add Domain
</Button>
</div>
</CardHeader>
<CardContent>
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
});
}}
/>
{searchParams.slug && (
<Button
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div>
<Table>
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
<TableRow className="grid grid-cols-7 items-center text-xs">
<TableHead className="col-span-1 flex items-center font-bold">
Domain
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Shorten Service
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Email Service
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
DNS Service
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((domain) => (
<div className="border-b" key={domain.id}>
<TableRow className="grid grid-cols-7 items-center">
<TableCell className="col-span-1 flex items-center gap-1">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${domain.domain_name}`}
target="_blank"
prefetch={false}
title={domain.domain_name}
>
{domain.domain_name}
</Link>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
defaultChecked={domain.enable_short_link}
onCheckedChange={(value) =>
handleChangeStatus(
value,
"enable_short_link",
domain,
)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
defaultChecked={domain.enable_email}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_email", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
defaultChecked={domain.enable_dns}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_dns", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
defaultChecked={domain.active}
onCheckedChange={(value) =>
handleChangeStatus(value, "active", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(domain.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditDomain(domain);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
{domain.cf_zone_id &&
domain.cf_api_key &&
domain.cf_email && (
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="ghost"
>
<Icons.cloudflare className="mx-0.5 size-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
<DomainInfo domain={domain} />
)} */}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any domains yet. Start creating one.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
{/* form */}
<Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<DomainForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditDomain}
action={action}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}
export function DomainInfo({ domain }: { domain: DomainFormData }) {
return <>{domain.domain_name}</>;
}
+12
View File
@@ -0,0 +1,12 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Domains Management" text="" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import UserRecordsList from "../../dashboard/records/record-list";
import DomainList from "./domain-list";
export const metadata = constructMetadata({
title: "DNS Records - WR.DO",
description: "List and manage records.",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;Domains"
text="List and manage domains."
link="/docs/domains"
linkText="domains."
/>
<DomainList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
email: user.email || "",
role: user.role,
team: user.team,
}}
action="/api/admin/domain"
/>
</>
);
}
@@ -33,7 +33,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import StatusDot from "@/components/dashboard/status-dot";
import { UserForm } from "@/components/forms/user-form";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
@@ -189,7 +189,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(!isShowForm);
}}
>
Add record
Add Record
</Button>
</div>
</CardHeader>
+1 -1
View File
@@ -179,7 +179,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
Add url
Add URL
</Button>
</div>
</CardHeader>
+147
View File
@@ -0,0 +1,147 @@
import { NextRequest } from "next/server";
import {
createDomain,
deleteDomain,
getAllDomains,
invalidateDomainConfigCache,
updateDomain,
} from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
// Get domains list
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
// TODO: Add pagination
const domains = await getAllDomains();
return Response.json(
{ list: domains, total: domains.length },
{ status: 200 },
);
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Create domain
export async function POST(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { data } = await req.json();
if (!data || !data.domain_name) {
return Response.json("domain_name is required", { status: 400 });
}
const newDomain = await createDomain({
domain_name: data.domain_name,
enable_short_link: !!data.enable_short_link,
enable_email: !!data.enable_email,
enable_dns: !!data.enable_dns,
cf_zone_id: data.cf_zone_id,
cf_api_key: data.cf_api_key,
cf_email: data.cf_email,
cf_api_key_encrypted: false,
max_short_links: data.max_short_links,
max_email_forwards: data.max_email_forwards,
max_dns_records: data.max_dns_records,
active: true,
});
invalidateDomainConfigCache();
return Response.json(newDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Update domain
export async function PUT(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const {
domain_name,
enable_short_link,
enable_email,
enable_dns,
cf_zone_id,
cf_api_key,
cf_email,
max_short_links,
max_email_forwards,
max_dns_records,
active,
id,
} = await req.json();
if (!id) {
return Response.json("domain id is required", { status: 400 });
}
const updatedDomain = await updateDomain(id, {
domain_name,
enable_short_link: !!enable_short_link,
enable_email: !!enable_email,
enable_dns: !!enable_dns,
active: !!active,
cf_zone_id,
cf_api_key,
cf_email,
cf_api_key_encrypted: false,
max_short_links,
max_email_forwards,
max_dns_records,
});
invalidateDomainConfigCache();
return Response.json(updatedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
// Delete domain
export async function DELETE(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", { status: 401 });
}
const { domain_name } = await req.json();
if (!domain_name) {
return Response.json("domain_name is required", { status: 400 });
}
const deletedDomain = await deleteDomain(domain_name);
invalidateDomainConfigCache();
return Response.json(deletedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import { FeatureMap, getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
// Get domains by feature for frontend
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const url = new URL(req.url);
const feature = url.searchParams.get("feature") || "";
if (!Object.keys(FeatureMap).includes(feature)) {
return Response.json(
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
{
status: 400,
},
);
}
const domainList = await getDomainsByFeature(FeatureMap[feature]);
return Response.json(domainList, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}
+14 -19
View File
@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -6,31 +5,27 @@ import {
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret, parseZones } from "@/lib/utils";
import { generateSecret } from "@/lib/utils";
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
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", {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "API key、zone iD and email are required",
statusText: "Please add at least one domain",
});
}
const { total } = await getUserRecordCount(user.id);
if (
user.role !== "ADMIN" &&
total >= TeamPlanQuota[user.team].RC_NewRecords
) {
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
@@ -49,7 +44,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -85,22 +80,22 @@ export async function POST(req: Request) {
}
const data = await createDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
if (!data.success || !data.result?.id) {
console.log("[data]", data);
// console.log("[data]", data);
return Response.json(data.messages, {
status: 501,
});
} else {
const res = await createUserRecord(user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
+12 -14
View File
@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -6,10 +5,11 @@ import {
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret, parseZones } from "@/lib/utils";
import { generateSecret } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -22,13 +22,11 @@ export async function POST(req: Request) {
});
}
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", {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "API key、zone iD and email are required",
statusText: "Please add at least one domain",
});
}
@@ -60,7 +58,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -96,9 +94,9 @@ export async function POST(req: Request) {
}
const data = await createDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
@@ -110,8 +108,8 @@ export async function POST(req: Request) {
} else {
const res = await createUserRecord(target_user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
+11 -17
View File
@@ -1,9 +1,8 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -24,20 +23,15 @@ export async function POST(req: Request) {
});
}
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 zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "Please add at least one domain",
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -48,9 +42,9 @@ export async function POST(req: Request) {
// force delete
await deleteUserRecord(userId, record_id, zone_id, active);
await deleteDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
+1 -2
View File
@@ -1,6 +1,5 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
+10 -16
View File
@@ -1,9 +1,8 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -16,16 +15,11 @@ export async function POST(req: Request) {
});
}
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "Missing required configuration",
},
{ status: 401, statusText: "Missing required configuration" },
);
}
@@ -44,7 +38,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -61,9 +55,9 @@ export async function POST(req: Request) {
}
const data = await updateDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
{ ...record, name: record_name },
);
@@ -80,8 +74,8 @@ export async function POST(req: Request) {
const res = await updateUserRecord(userId, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
+11 -16
View File
@@ -1,9 +1,9 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -12,20 +12,15 @@ export async function POST(req: Request) {
const { record_id, zone_id, active } = await req.json();
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 zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "Please add at least one domain",
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -34,9 +29,9 @@ export async function POST(req: Request) {
}
const res = await deleteDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);
-1
View File
@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
+15 -21
View File
@@ -1,13 +1,12 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import {
updateUserRecord,
updateUserRecordState,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
// Update DNS record
export async function POST(req: Request) {
@@ -15,10 +14,8 @@ export async function POST(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
@@ -40,7 +37,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -64,14 +61,13 @@ export async function POST(req: Request) {
}
const data = await updateDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
{ ...record, name: record_name },
);
console.log("[updateDNSRecord]", data);
if (!data.success || !data.result?.id) {
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
@@ -81,8 +77,8 @@ export async function POST(req: Request) {
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
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
@@ -118,10 +114,8 @@ export async function PUT(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
@@ -136,7 +130,7 @@ export async function PUT(req: Request) {
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -153,9 +147,9 @@ export async function PUT(req: Request) {
isTargetAccessible = target_res.status === 200;
} catch (fetchError) {
isTargetAccessible = false;
console.log(
`[Fetch Error] Failed to access target ${target}: ${fetchError}`,
);
// console.log(
// `[Fetch Error] Failed to access target ${target}: ${fetchError}`,
// );
}
const res = await updateUserRecordState(
+13
View File
@@ -1,4 +1,5 @@
import { TeamPlanQuota } from "@/config/team";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -23,6 +24,18 @@ export async function POST(req: Request) {
const { target, url, prefix, visible, active, expiration, password } =
createUrlSchema.parse(data);
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 400,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",
-1
View File
@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
+7 -1
View File
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
import { reservedAddressSuffix } from "@/lib/enums";
import { restrictByTimeRange } from "@/lib/team";
@@ -53,7 +54,12 @@ export async function POST(req: NextRequest) {
status: 400,
});
}
if (!siteConfig.emailDomains.includes(suffix)) {
const zones = await getDomainsByFeature("enable_email", true);
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(suffix)
) {
return NextResponse.json("Invalid email suffix address", { status: 400 });
}
+12
View File
@@ -1,5 +1,6 @@
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { restrictByTimeRange } from "@/lib/team";
import { createUrlSchema } from "@/lib/validations/url";
@@ -41,6 +42,17 @@ export async function POST(req: Request) {
});
}
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 409,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
@@ -1,5 +1,7 @@
import React from "react";
import { cn } from "@/lib/utils";
interface SectionColumnsType {
title: string;
children: React.ReactNode;
@@ -14,7 +16,12 @@ export function FormSectionColumns({
className,
}: SectionColumnsType) {
return (
<div className="grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
<div
className={cn(
"grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2",
className,
)}
>
<div className="col-span-4 flex items-start gap-0.5 text-sm leading-none">
<h2 className="font-semibold">{title}</h2>
{required && (
+38 -22
View File
@@ -77,9 +77,7 @@ export default function EmailSidebar({
const [isEdit, setIsEdit] = useState(false);
const [showEmailModal, setShowEmailModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [domainSuffix, setDomainSuffix] = useState<string | null>(
siteConfig.shortDomains[0],
);
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
const [deleteInput, setDeleteInput] = useState("");
@@ -109,6 +107,13 @@ export default function EmailSidebar({
},
);
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
{ domain_name: string }[]
>("/api/domain?feature=email", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10000,
});
if (!selectedEmailAddress && data && data.list.length > 0) {
onSelectEmail(data.list[0].emailAddress);
}
@@ -595,25 +600,36 @@ export default function EmailSidebar({
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
}
/>
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.emailDomains.map((v) => (
<SelectItem key={v} value={v}>
@{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoadingDomains ? (
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
) : (
emailDomains && (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={
domainSuffix || emailDomains[0].domain_name
}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains.map((v) => (
<SelectItem
key={v.domain_name}
value={v.domain_name}
>
@{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
)}
<Button
className="rounded-l-none"
type="button"
+417
View File
@@ -0,0 +1,417 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { DomainFormData } from "@/lib/dto/domains";
import { EXPIRATION_ENUMS } from "@/lib/enums";
import { generateUrlSuffix } from "@/lib/utils";
import { createDomainSchema } from "@/lib/validations/domain";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/shared/icons";
import { FormSectionColumns } from "../dashboard/form-section-columns";
import { Badge } from "../ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
export type FormData = DomainFormData;
export type FormType = "add" | "edit";
export interface DomainFormProps {
user: Pick<User, "id" | "name">;
isShowForm: boolean;
setShowForm: Dispatch<SetStateAction<boolean>>;
type: FormType;
initData?: DomainFormData | null;
action: string;
onRefresh: () => void;
}
export function DomainForm({
setShowForm,
type,
initData,
action,
onRefresh,
}: DomainFormProps) {
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const [currentRecordStatus, setCurrentRecordStatus] = useState(
initData?.enable_dns || false,
);
const {
handleSubmit,
register,
formState: { errors },
getValues,
setValue,
} = useForm<FormData>({
resolver: zodResolver(createDomainSchema),
defaultValues: {
id: initData?.id || "",
domain_name: initData?.domain_name || "",
enable_short_link: initData?.enable_short_link || false,
enable_email: initData?.enable_email || false,
enable_dns: initData?.enable_dns || false,
cf_zone_id: initData?.cf_zone_id || "",
cf_api_key: initData?.cf_api_key || "",
cf_email: initData?.cf_email || "",
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
max_short_links: initData?.max_short_links || 0,
max_email_forwards: initData?.max_email_forwards || 0,
max_dns_records: initData?.max_dns_records || 0,
active: initData?.active || true,
},
});
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateDomain(data);
} else if (type === "edit") {
handleUpdateDomain(data);
}
});
const handleCreateDomain = async (data: DomainFormData) => {
startTransition(async () => {
const response = await fetch(`${action}`, {
method: "POST",
body: JSON.stringify({
data,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Created Failed!", {
description: await response.text(),
});
} else {
// const res = await response.json();
toast.success(`Created successfully!`);
setShowForm(false);
onRefresh();
}
});
};
const handleUpdateDomain = async (data: DomainFormData) => {
startTransition(async () => {
if (type === "edit") {
const response = await fetch(`${action}`, {
method: "PUT",
body: JSON.stringify(data),
});
if (!response.ok || response.status !== 200) {
toast.error("Update Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Update successfully!`);
setShowForm(false);
onRefresh();
}
}
});
};
const handleDeleteDomain = async () => {
if (type === "edit") {
startDeleteTransition(async () => {
const response = await fetch(`${action}`, {
method: "DELETE",
body: JSON.stringify({
domain_name: initData?.domain_name,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Delete Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Success`);
setShowForm(false);
onRefresh();
}
});
}
};
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? "Create" : "Edit"} Domain
</div>
<form className="p-4" onSubmit={onSubmit}>
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Base
</h2>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
Domain Name:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("domain_name")}
/>
<div className="flex flex-col justify-between p-1">
{errors?.domain_name ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.domain_name.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. eg: example.com
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="active">
Active:
</Label>
<Switch
id="active"
{...register("active")}
defaultChecked={initData?.active ?? true}
onCheckedChange={(value) => setValue("active", value)}
/>
</div>
</div>
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Services(Optional)
</h2>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="short_url_service">
Short URL Service:
</Label>
<Switch
id="short_url_service"
{...register("enable_short_link")}
defaultChecked={initData?.enable_short_link ?? false}
onCheckedChange={(value) => setValue("enable_short_link", value)}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="email_service">
Email Service:
</Label>
<Switch
id="email_service"
{...register("enable_email")}
defaultChecked={initData?.enable_email ?? false}
onCheckedChange={(value) => setValue("enable_email", value)}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<Label className="cursor-pointer" htmlFor="dns_record_service">
DNS Record Service:
</Label>
<Switch
id="dns_record_service"
{...register("enable_dns")}
defaultChecked={initData?.enable_dns ?? false}
onCheckedChange={(value) => {
setValue("enable_dns", value);
setCurrentRecordStatus(value);
}}
/>
</div>
</div>
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
Cloudflare Configs(Optional)
</h2>
<Icons.chevronDown className="ml-auto size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
{!currentRecordStatus && (
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
<Icons.help className="size-3" /> Associate with "DNS Record
Service" status
</div>
)}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
Zone ID:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_zone_id")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_zone_id ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_zone_id.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get zone id?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
API Token:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_api_key")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_api_key ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_api_key.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get api token?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="email">
Account Email:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_email")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_email ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_email.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get cloudflare account email?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
</CollapsibleContent>
</Collapsible>
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
{type === "edit" && (
<Button
type="button"
variant="destructive"
className="mr-auto w-[80px] px-0"
onClick={() => handleDeleteDomain()}
disabled={isDeleting}
>
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Delete</p>
)}
</Button>
)}
<Button
type="reset"
variant="outline"
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
Cancle
</Button>
<Button
type="submit"
variant="blue"
disabled={isPending}
className="w-[80px] shrink-0 px-0"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? "Update" : "Save"}</p>
)}
</Button>
</div>
</form>
</div>
);
}
+39 -20
View File
@@ -5,11 +5,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
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";
import { fetcher } from "@/lib/utils";
import { createRecordSchema } from "@/lib/validations/record";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -24,6 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Skeleton } from "../ui/skeleton";
import { Switch } from "../ui/switch";
export type FormData = CreateDNSRecord;
@@ -77,6 +80,16 @@ export function RecordForm({
},
});
// Fetch the record domains
const { data: recordDomains, isLoading } = useSWR<{ domain_name: string }[]>(
"/api/domain?feature=record",
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateRecord(data);
@@ -196,26 +209,32 @@ export function RecordForm({
<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>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
recordDomains && (
<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>
{recordDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
)}
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
</p>
+40 -64
View File
@@ -6,11 +6,12 @@ import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import { siteConfig } from "@/config/site";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { EXPIRATION_ENUMS } from "@/lib/enums";
import { generateUrlSuffix } from "@/lib/utils";
import { fetcher, generateUrlSuffix } from "@/lib/utils";
import { createUrlSchema } from "@/lib/validations/url";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -25,7 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
import { Skeleton } from "../ui/skeleton";
export type FormData = ShortUrlFormData;
@@ -64,13 +65,22 @@ export function UrlForm({
target: initData?.target || "",
url: initData?.url || "",
active: initData?.active || 1,
prefix: initData?.prefix || siteConfig.shortDomains[0],
prefix: initData?.prefix || "wr.do",
visible: initData?.visible || 0,
expiration: initData?.expiration || "-1",
password: initData?.password || "",
},
});
const { data: shortDomains, isLoading } = useSWR<{ domain_name: string }[]>(
"/api/domain?feature=short",
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateUrl(data);
@@ -191,25 +201,33 @@ export function UrlForm({
</Label>
<div className="relative flex w-full items-center">
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || siteConfig.shortDomains[0]}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.shortDomains.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? (
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
) : (
shortDomains && (
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={
initData?.prefix || shortDomains[0].domain_name
}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{shortDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))}
</SelectContent>
</Select>
)
)}
<Input
id="url"
className="w-full rounded-none pl-[8px] shadow-inner"
@@ -296,48 +314,6 @@ export function UrlForm({
Expiration time, default for never.
</p>
</FormSectionColumns>
{/* <div>
<p className="text-sm text-gray-700 dark:text-white">
Your Final URL:
</p>
<p className="text-sm text-gray-700 dark:text-white">
{getValues("prefix")}/s/{getValues("url")}
</p>
</div> */}
{/* <FormSectionColumns title="Visible">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="visible">
Visible
</Label>
<Switch
id="visible"
{...register("visible")}
disabled
defaultChecked={initData?.visible === 1 || false}
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Public or private short url.
</p>
</FormSectionColumns> */}
{/* <FormSectionColumns title="Active">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="active">
Active
</Label>
<Switch
id="active"
{...register("active")}
defaultChecked={initData?.active === 1 || true}
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Enable or disable short url.
</p>
</FormSectionColumns> */}
</div>
{/* Action buttons */}
+29
View File
@@ -301,4 +301,33 @@ export const Icons = {
</g>
</svg>
),
cloudflare: ({ ...props }: LucideProps) => (
<svg
width="800px"
height="800px"
viewBox="0 -70 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
preserveAspectRatio="xMidYMid"
{...props}
>
<g>
<g transform="translate(0.000000, -1.000000)">
<path
d="M202.3569,50.394 L197.0459,48.27 C172.0849,104.434 72.7859,70.289 66.8109,86.997 C65.8149,98.283 121.0379,89.143 160.5169,91.056 C172.5559,91.639 178.5929,100.727 173.4809,115.54 L183.5499,115.571 C195.1649,79.362 232.2329,97.841 233.7819,85.891 C231.2369,78.034 191.1809,85.891 202.3569,50.394 Z"
fill="#FFFFFF"
></path>
<path
d="M176.332,109.3483 C177.925,104.0373 177.394,98.7263 174.739,95.5393 C172.083,92.3523 168.365,90.2283 163.585,89.6973 L71.17,88.6343 C70.639,88.6343 70.108,88.1033 69.577,88.1033 C69.046,87.5723 69.046,87.0413 69.577,86.5103 C70.108,85.4483 70.639,84.9163 71.701,84.9163 L164.647,83.8543 C175.801,83.3233 187.486,74.2943 191.734,63.6723 L197.046,49.8633 C197.046,49.3313 197.577,48.8003 197.046,48.2693 C191.203,21.1823 166.772,0.9993 138.091,0.9993 C111.535,0.9993 88.697,17.9953 80.73,41.8963 C75.419,38.1783 69.046,36.0533 61.61,36.5853 C48.863,37.6473 38.772,48.2693 37.178,61.0163 C36.647,64.2033 37.178,67.3903 37.71,70.5763 C16.996,71.1073 0,88.1033 0,109.3483 C0,111.4723 0,113.0663 0.531,115.1903 C0.531,116.2533 1.593,116.7843 2.125,116.7843 L172.614,116.7843 C173.676,116.7843 174.739,116.2533 174.739,115.1903 L176.332,109.3483 Z"
fill="#F4811F"
></path>
<path
d="M205.5436,49.8628 L202.8876,49.8628 C202.3566,49.8628 201.8256,50.3938 201.2946,50.9248 L197.5766,63.6718 C195.9836,68.9828 196.5146,74.2948 199.1706,77.4808 C201.8256,80.6678 205.5436,82.7918 210.3236,83.3238 L229.9756,84.3858 C230.5066,84.3858 231.0376,84.9168 231.5686,84.9168 C232.0996,85.4478 232.0996,85.9788 231.5686,86.5098 C231.0376,87.5728 230.5066,88.1038 229.4436,88.1038 L209.2616,89.1658 C198.1076,89.6968 186.4236,98.7258 182.1746,109.3478 L181.1116,114.1288 C180.5806,114.6598 181.1116,115.7218 182.1746,115.7218 L252.2826,115.7218 C253.3446,115.7218 253.8756,115.1908 253.8756,114.1288 C254.9376,109.8798 255.9996,105.0998 255.9996,100.3188 C255.9996,72.7008 233.1616,49.8628 205.5436,49.8628"
fill="#FAAD3F"
></path>
</g>
</g>
</svg>
),
};
+7 -1
View File
@@ -11,7 +11,7 @@ export const sidebarLinks: SidebarNavItem[] = [
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
{ href: "/emails", icon: "mail", title: "Emails" },
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
{ href: "/chat", icon: "messages", title: "WRoom" },
],
},
@@ -54,6 +54,12 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Admin Panel",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/domains",
icon: "globeLock",
title: "Domains",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/users",
icon: "users",
-6
View File
@@ -3,10 +3,7 @@ import { env } from "@/env.mjs";
const site_url = env.NEXT_PUBLIC_APP_URL;
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",
@@ -23,9 +20,6 @@ export const siteConfig: SiteConfig = {
},
mailSupport: "support@wr.do",
openSignup: open_signup === "1" ? true : false,
shortDomains: short_domains.split(","),
emailDomains: email_domains.split(","),
recordDomains: record_domains.split(","),
emailR2Domain: email_r2_domain,
};
+43
View File
@@ -5,6 +5,49 @@ description: How to config the cloudflare api.
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
## Overview
Administrators can manage domain configurations at `/admin/domains`, including adding, deleting, and modifying domains.
![](/_static/docs/domain-form.png)
### Domain Configuration Form
The `Short URL Service` and `Email Service` require no additional configuration and can be activated to enable short URL and email functionalities (email requires worker forwarding setup).
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
- Zone ID
- API Token
- Email
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
### Zone ID
The unique identifier for a domain hosted on Cloudflare, located at:
https://dash.cloudflare.com/[account_id]/[zone_name]
### API Token
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
### Email
Email for registering a Cloudflare account
<Callout type="info">
You can manage domains hosted under different Cloudflare accounts,
provided the API Token and Email are sourced from the same account.
</Callout>
---
# This section is Deprecated since version v0.6.0
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"
-9
View File
@@ -48,21 +48,12 @@ 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 | `[{"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. |
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
| NEXT_PUBLIC_SHORT_DOMAINS | `wr.do,uv.do` | The list of short domains. Separated by `,` |
| NEXT_PUBLIC_EMAIL_DOMAINS | `wr.do,uv.do` | The list of email domains. Separated by `,` |
> `NEXT_PUBLIC_SHORT_DOMAINS`、`NEXT_PUBLIC_EMAIL_DOMAINS` is used to limit the short domain name and email domain name.
- 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`、`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).
+21 -44
View File
@@ -136,40 +136,7 @@ RESEND_API_KEY = re_your_resend_api_key;
</Steps>
## 3. Cloudflare Configs
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
### Add the CLOUDFLARE_ZONE Environment Variable
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`.
### Add the CLOUDFLARE_API_KEY Environment Variable
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.
> 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.
### Add the CLOUDFLARE_EMAIL Environment Variable
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
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=[{"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
```
## 4. Email Worker Configs
## 3. Email Worker Configs
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
@@ -187,20 +154,14 @@ Via:
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
```
## 5. Add the Bussiness Configs
## 4. Add the Bussiness Configs
```js title=".env"
# Allow anyone to sign up
NEXT_PUBLIC_OPEN_SIGNUP=1
# Short domains. Separated by `,`
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
# Email domains. Separated by `,`
NEXT_PUBLIC_EMAIL_DOMAINS=wr.do,uv.do
```
## 6. Add the SCREENSHOTONE_BASE_URL Environment Variable
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
It's the base URL for the screenshotone API.
@@ -211,20 +172,36 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
```
## 7. Add the GITHUB_TOKEN Environment Variable
## 6. Add the GITHUB_TOKEN Environment Variable
Via https://github.com/settings/tokens to get your token.
```js title=".env"
GITHUB_TOKEN=
```
## 8. Start the Dev Server
## 7. Start the Dev Server
```bash
pnpm dev
```
Via [http://localhost:3000](http://localhost:3000)
## 8. Setup System
#### Create the first account and Change the account's role to ADMIN
Follow the steps below:
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
- 3. Then follow the **panel guide** to config the system and add the first domain.
<Callout type="info">
After change change the account's role to ADMIN, and then you can refresh the website and access http://localhost:3000/admin.
<strong>You must add at least one domain to start using short links, email or subdomain management functions.</strong>
</Callout>
## Q & A
-13
View File
@@ -15,19 +15,13 @@ export const env = createEnv({
LinuxDo_CLIENT_SECRET: z.string().optional(),
DATABASE_URL: z.string().min(1),
RESEND_API_KEY: z.string().optional(),
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(),
GITHUB_TOKEN: z.string().optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().min(1),
NEXT_PUBLIC_OPEN_SIGNUP: z.string().min(1).default("1"),
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,
@@ -40,14 +34,7 @@ export const env = createEnv({
RESEND_API_KEY: process.env.RESEND_API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_OPEN_SIGNUP: process.env.NEXT_PUBLIC_OPEN_SIGNUP,
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,
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,
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
LinuxDo_CLIENT_ID: process.env.LinuxDo_CLIENT_ID,
+36
View File
@@ -0,0 +1,36 @@
import { getAllDomains, invalidateDomainConfigCache } from "@/lib/dto/domains";
export async function getDomainConfig() {
return await getAllDomains();
}
export async function getCloudflareCredentials(domain_name: string) {
try {
const domains = await getAllDomains();
const domain = domains.find((d) => d.domain_name === domain_name);
if (!domain || !domain.cf_api_key || !domain.cf_email) {
throw new Error(
`No Cloudflare credentials found for domain: ${domain_name}`,
);
}
let apiKey = domain.cf_api_key;
if (domain.cf_api_key_encrypted) {
// TODO
apiKey = decrypt(apiKey);
}
return {
api_key: apiKey,
email: domain.cf_email,
};
} catch (error) {
throw new Error(`Failed to fetch credentials: ${error.message}`);
}
}
function decrypt(encryptedKey: string) {
return encryptedKey; // Replace with actual decryption logic
}
export { invalidateDomainConfigCache };
+122
View File
@@ -0,0 +1,122 @@
import { Domain } from "@prisma/client";
import { prisma } from "../db";
// In-memory cache
let domainConfigCache: Domain[] | null = null;
let lastCacheUpdate = 0;
const CACHE_DURATION = 60 * 1000; // Cache for 1 minute in memory
export const FeatureMap = {
short: "enable_short_link",
email: "enable_email",
record: "enable_dns",
};
export interface DomainConfig {
domain_name: string;
enable_short_link: boolean;
enable_email: boolean;
enable_dns: boolean;
cf_zone_id: string | null;
cf_api_key: string | null;
cf_email: string | null;
cf_api_key_encrypted: boolean;
max_short_links: number | null;
max_email_forwards: number | null;
max_dns_records: number | null;
active: boolean;
}
export interface DomainFormData extends DomainConfig {
id?: string;
createdAt: Date;
updatedAt: Date;
}
export async function getAllDomains() {
try {
const now = Date.now();
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
return domainConfigCache;
}
const domains = await prisma.domain.findMany({
// where: { active: true },
});
domainConfigCache = domains;
lastCacheUpdate = now;
return domains;
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function getDomainsByFeature(
feature: string,
admin: boolean = false,
) {
try {
const now = Date.now();
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
return domainConfigCache;
}
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
domain_name: true,
enable_short_link: admin,
enable_email: admin,
enable_dns: admin,
cf_zone_id: admin,
cf_api_key: admin,
cf_email: admin,
},
});
return domains;
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function createDomain(data: DomainConfig) {
try {
const createdDomain = await prisma.domain.create({ data });
return createdDomain;
} catch (error) {
throw new Error(`Failed to create domain: ${error.message}`);
}
}
export async function updateDomain(id: string, data) {
try {
const updatedDomain = await prisma.domain.update({
where: { id },
data: {
...data,
updatedAt: new Date(),
},
});
return updatedDomain;
} catch (error) {
throw new Error(`Failed to update domain: ${error.message}`);
}
}
export async function deleteDomain(domain_name: string) {
try {
const deletedDomain = await prisma.domain.delete({
where: { domain_name },
});
return deletedDomain;
} catch (error) {
throw new Error(`Failed to delete domain`);
}
}
export function invalidateDomainConfigCache() {
domainConfigCache = null;
lastCacheUpdate = 0;
}
-22
View File
@@ -388,25 +388,3 @@ 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;
}
+17
View File
@@ -0,0 +1,17 @@
import * as z from "zod";
export const createDomainSchema = z.object({
id: z.string().optional(),
domain_name: z.string().min(2),
enable_short_link: z.boolean(),
enable_email: z.boolean(),
enable_dns: z.boolean(),
cf_zone_id: z.string().optional(),
cf_api_key: z.string().optional(),
cf_email: z.string().optional(),
cf_api_key_encrypted: z.boolean().default(false),
max_short_links: z.number().optional(),
max_email_forwards: z.number().optional(),
max_dns_records: z.number().optional(),
active: z.boolean().default(true),
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "0.5.1",
"version": "0.6.0",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -0,0 +1,18 @@
CREATE TABLE domains
(
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"domain_name" TEXT NOT NULL UNIQUE,
"enable_short_link" BOOLEAN DEFAULT FALSE,
"enable_email" BOOLEAN DEFAULT FALSE,
"enable_dns" BOOLEAN DEFAULT FALSE,
"cf_zone_id" TEXT NOT NULL,
"cf_api_key" TEXT NOT NULL,
"cf_email" TEXT NOT NULL,
"cf_api_key_encrypted" BOOLEAN DEFAULT FALSE,
"max_short_links" INTEGER,
"max_email_forwards" INTEGER,
"max_dns_records" INTEGER,
"active" BOOLEAN DEFAULT TRUE,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+21
View File
@@ -247,3 +247,24 @@ model UserSendEmail {
@@index([createdAt])
@@map("user_send_emails")
}
model Domain {
id String @id @default(uuid())
domain_name String @unique
enable_short_link Boolean @default(false)
enable_email Boolean @default(false)
enable_dns Boolean @default(false)
cf_zone_id String?
cf_api_key String?
cf_email String?
cf_api_key_encrypted Boolean
max_short_links Int?
max_email_forwards Int?
max_dns_records Int?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@map("domains")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.0",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+36 -36
View File
@@ -1,39 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>
+101 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-3
View File
@@ -17,9 +17,6 @@ export type SiteConfig = {
oichat: string;
};
openSignup: boolean;
shortDomains: string[];
emailDomains: string[];
recordDomains: string[];
emailR2Domain: string;
};