Merge pull request #20 from oiov/config-domains

Config domains and setup guide
This commit is contained in:
oiov
2025-05-21 16:39:37 +08:00
committed by GitHub
60 changed files with 2108 additions and 409 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
+14 -3
View File
@@ -56,11 +56,22 @@ pnpm install
pnpm dev
```
#### 初始化数据库
```bash
pnpm postinstall
pnpm db:push
```
#### 激活管理员面板
Follow https://localhost:3000/setup
## 社区群组
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## 许可证
+14 -3
View File
@@ -57,6 +57,17 @@ pnpm install
pnpm dev
```
#### Init database
```bash
pnpm postinstall
pnpm db:push
```
#### Setup Admin Panel
Follow https://localhost:3000/setup
## Legitimacy review
- To avoid abuse, applications without website content will be rejected
@@ -68,9 +79,9 @@ pnpm dev
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## License
@@ -0,0 +1,357 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { 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 { fetcher, timeAgo } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
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
disabled
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>
+6 -11
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import DashboardScrapeCharts from "./charts";
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
linkText="Open API."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Screenshot"
desc="Take a screenshot of the webpage."
link="/dashboard/scrape/screenshot"
icon="camera"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Meta Info"
desc="Extract website metadata."
link="/dashboard/scrape/meta-info"
icon="globe"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to QR Code"
desc="Generate QR Code from URL."
link="/dashboard/scrape/qrcode"
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Markdown"
desc="Convert website content to Markdown format."
link="/dashboard/scrape/markdown"
icon="heading1"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Text"
desc="Extract website text."
link="/dashboard/scrape/markdown"
+1 -2
View File
@@ -179,7 +179,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
Add url
Add URL
</Button>
</div>
</CardHeader>
@@ -345,7 +345,6 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Switch
className="data-[state=checked]:bg-blue-500"
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
+358
View File
@@ -0,0 +1,358 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons";
export default function StepGuide({
user,
}: {
user: { id: string; email: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const [isMobile, setIsMobile] = useState(false);
const steps = [
{
id: 1,
title: "Set up an administrator",
description:
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
component: () => <SetAdminRole id={user.id} email={user.email} />,
},
{
id: 2,
title: "Add the first domain",
description:
"Check out your reimagined site and click to Migrate & Download.",
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: "Congrats on completing setup 🎉",
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
component: () => <Congrats />,
},
];
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
setCurrentStep(currentStep + 1);
if (!completedSteps.includes(currentStep)) {
setCompletedSteps([...completedSteps, currentStep]);
}
} else if (currentStep === steps.length) {
router.push("/admin");
}
};
const goToPreviousStep = () => {
if (currentStep > 1) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
const currentStepData =
steps.find((step) => step.id === currentStep) || steps[0];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
};
return (
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
</span>
<span className="text-muted-foreground">of</span>
<span>{steps.length}</span>
</div>
</div>
{/* Content area */}
<div className="relative w-full rounded-lg">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
<motion.h3
className="text-base font-semibold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.title}
</motion.h3>
</div>
<motion.div
className="h-full"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.component()}
</motion.div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
<motion.div
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<button
onClick={goToPreviousStep}
disabled={currentStep === 1}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
currentStep === 1
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<button
onClick={goToNextStep}
// disabled={currentStep === steps.length}
className={cn(
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? "🚀 Start" : "Next"}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
</Modal>
);
}
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
if (res.ok) {
setIsAdmin(true);
}
});
};
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
Ready
</Badge>
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
Allow Sign Up:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-500">
Set {email} as ADMIN:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
className=""
variant={"outline"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Active Now
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel
and add domain names.
</p>
<p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view
and edit all resources (short links, subdomains, email), etc.
</p>
<p>
📢 Via{" "}
<a
className="text-blue-500"
target="_blank"
href="/docs/developer/quick-start"
>
quick start
</a>{" "}
docs to get more information.
</p>
</div>
</div>
);
}
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/domain", {
method: "POST",
body: JSON.stringify({
data: {
domain_name: removeUrlSuffix(domain),
enable_short_link: true,
enable_email: true,
enable_dns: true,
cf_zone_id: "",
cf_api_key: "",
cf_email: "",
cf_api_key_encrypted: false,
max_short_links: 0,
max_email_forwards: 0,
max_dns_records: 0,
active: true,
},
}),
});
if (res.ok) {
onNextStep();
} else {
toast.error("Created Failed!", {
description: await res.text(),
});
}
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4">
<FormSectionColumns title="Domain Name">
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
Domain Name
</Label>
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare).
</p>
</div>
<div className="mt-2 flex w-full items-center justify-end gap-3">
<Button
className="text-xs text-muted-foreground"
variant={"ghost"}
size={"sm"}
onClick={onNextStep}
>
Or add later
</Button>
<Button
className="flex items-center gap-1"
size={"sm"}
variant={"blue"}
disabled={isPending}
onClick={handleCreateDomain}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Submit
</Button>
</div>
</FormSectionColumns>
</div>
);
}
function Congrats() {
return <></>;
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getAllUsersCount } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import SetupGuide from "./guide";
export const metadata = constructMetadata({
title: "Setup Guide",
description: "Setup Guide",
});
export default async function SetupPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
if (user.role === "ADMIN") redirect("/admin");
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
}
return redirect("/admin");
}
+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 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest } from "next/server";
import { FeatureMap, getDomainsByFeatureClient } 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 getDomainsByFeatureClient(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(
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import {
checkUserStatus,
getAllUsersCount,
setFirstUserAsAdmin,
} from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
const res = await setFirstUserAsAdmin(user.id);
if (res) {
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
}
return Response.json({ admin: false }, { status: 400 });
}
return redirect("/admin");
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}
+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.1",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",
+1 -3
View File
@@ -145,14 +145,12 @@ export function HeroCard({
);
}
export async function ScrapeInfoCard({
userId,
export async function StaticInfoCard({
title,
desc,
link,
icon = "users",
}: {
userId: string;
title: string;
desc?: string;
link: string;
@@ -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 && (
+40 -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,38 @@ 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" />
) : (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || "wr.do"}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains && emailDomains.length > 0 ? (
emailDomains.map((v) => (
<SelectItem
key={v.domain_name}
value={v.domain_name}
>
@{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Button
className="rounded-l-none"
type="button"
+418
View File
@@ -0,0 +1,418 @@
"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)}
disabled
/>
</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>
);
}
+43 -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,36 @@ 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" />
) : (
<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 && recordDomains.length > 0 ? (
recordDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
</p>
+42 -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,35 @@ 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" />
) : (
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || "wr.do"}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{shortDomains && shortDomains.length > 0 ? (
shortDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Input
id="url"
className="w-full rounded-none pl-[8px] shadow-inner"
@@ -296,48 +316,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>
),
};
+2
View File
@@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground",
blue: "bg-blue-500 hover:bg-blue-600 border-transparent text-white",
green: "bg-green-500 hover:bg-green-600 border-transparent text-white",
},
},
defaultVariants: {
+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"
-10
View File
@@ -48,22 +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).
For step by step installation, see [Quick Start](/docs/developer/quick-start).
+31 -63
View File
@@ -45,25 +45,23 @@ DATABASE_URL=
### Deploy Postgres
#### Manually install (Recommended)
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations/20240705091917_init/migration.sql),
copy the sql code to the database to initialize the database schema.
#### or
```bash
pnpm postinstall
pnpm db:push
```
#### Or manually init
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations),
copy the sql code to the database to initialize the database schema.
### Add the AUTH_SECRET Environment Variable
The `AUTH_SECRET` environment variable is used to encrypt tokens and email verification hashes(NextAuth.js).
You can generate one from https://generate-secret.vercel.app/32:
```js title=".env"
AUTH_SECRET=a3e686f39b2a878c6866e4604e6f1b1b
AUTH_SECRET=10000032bsfasfafk4lkkfsa
```
## 2. Configure Authentication Service
@@ -136,40 +134,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 +152,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 +170,40 @@ 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.
![](/_static/docs/setup-1.png)
![](/_static/docs/setup-2.png)
<Callout type="info">
After change the account's role to ADMIN, 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 features.</strong>
</Callout>
## Q & A
@@ -238,17 +217,6 @@ https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
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:
-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 };
+136
View File
@@ -0,0 +1,136 @@
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 getDomainsByFeatureClient(feature: string) {
try {
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
domain_name: true,
},
});
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;
}
+11
View File
@@ -85,6 +85,17 @@ export async function getAllUsersCount() {
}
}
export async function setFirstUserAsAdmin(userId: string) {
try {
return await prisma.user.update({
where: { id: userId },
data: { role: UserRole.ADMIN },
});
} catch (error) {
return null;
}
}
export async function getAllUsersActiveApiKeyCount() {
try {
return await prisma.user.count({ where: { apiKey: { not: null } } });
-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.1",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -274,7 +274,7 @@ CREATE TABLE "user_send_emails"
"html" TEXT DEFAULT '',
"replyTo" TEXT DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 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.1",
"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.1",
"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-10T03:08:41.904Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-10T03:08:41.905Z</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>
+1 -1
View File
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;
};