feat: support multi domain configuration and management features (database)
This commit is contained in:
@@ -25,14 +25,6 @@ DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=requ
|
||||
# -----------------------------------------------------------------------------
|
||||
RESEND_API_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Cloudflare
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=wr.do,uv.do
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc123", "zone_name": "wr.do"},{"zone_id":"abc456", "zone_name": "uv.do"}]
|
||||
CLOUDFLARE_API_KEY=
|
||||
CLOUDFLARE_EMAIL=
|
||||
|
||||
# Open Signup
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
@@ -45,7 +37,5 @@ SCREENSHOTONE_BASE_URL=https://shot.wr.do
|
||||
# GitHub api token for getting gitHub stars count
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Short domains, split by ","
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Domain, User } from "@prisma/client";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import {
|
||||
cn,
|
||||
expirationTime,
|
||||
fetcher,
|
||||
removeUrlSuffix,
|
||||
timeAgo,
|
||||
} from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DomainForm } from "@/components/forms/domain-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
import { PaginationWrapper } from "@/components/shared/pagination";
|
||||
|
||||
export interface DomainListProps {
|
||||
user: Pick<User, "id" | "name" | "email" | "apiKey" | "role" | "team">;
|
||||
action: string;
|
||||
}
|
||||
|
||||
function TableColumnSekleton() {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-7 items-center">
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DomainList({ user, action }: DomainListProps) {
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditDomain, setCurrentEditDomain] =
|
||||
useState<DomainFormData | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [isShowDomainInfo, setShowDomainInfo] = useState(false);
|
||||
const [selectedDomain, setSelectedDomain] = useState<DomainFormData | null>(
|
||||
null,
|
||||
);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
slug: "",
|
||||
target: "",
|
||||
userName: "",
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, isLoading } = useSWR<{
|
||||
total: number;
|
||||
list: DomainFormData[];
|
||||
}>(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
mutate(
|
||||
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
||||
undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeStatus = async (
|
||||
checked: boolean,
|
||||
target: string,
|
||||
domain: DomainFormData,
|
||||
) => {
|
||||
const res = await fetch(action, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
id: domain.id,
|
||||
enable_short_link:
|
||||
target === "enable_short_link" ? checked : domain.enable_short_link,
|
||||
enable_email: target === "enable_email" ? checked : domain.enable_email,
|
||||
enable_dns: target === "enable_dns" ? checked : domain.enable_dns,
|
||||
active: target === "active" ? checked : domain.active,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
toast.success("Successed!");
|
||||
}
|
||||
} else {
|
||||
toast.error("Activation failed!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center">
|
||||
<CardDescription className="text-balance text-lg font-bold">
|
||||
<span>Total Domains:</span>{" "}
|
||||
<span className="font-bold">{data && data.total}</span>
|
||||
</CardDescription>
|
||||
|
||||
<div className="ml-auto flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => handleRefresh()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCwIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
className="h-8 text-xs md:text-xs"
|
||||
placeholder="Search by slug..."
|
||||
value={searchParams.slug}
|
||||
onChange={(e) => {
|
||||
setSearchParams({
|
||||
...searchParams,
|
||||
slug: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{searchParams.slug && (
|
||||
<Button
|
||||
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setSearchParams({ ...searchParams, slug: "" })}
|
||||
variant={"ghost"}
|
||||
>
|
||||
<Icons.close className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
||||
<TableRow className="grid grid-cols-7 items-center text-xs">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Domain
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Shorten Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Email Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
DNS Service
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
|
||||
Active
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Updated
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.list && data.list.length ? (
|
||||
data.list.map((domain) => (
|
||||
<div className="border-b" key={domain.id}>
|
||||
<TableRow className="grid grid-cols-7 items-center">
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Link
|
||||
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
||||
href={`https://${domain.domain_name}`}
|
||||
target="_blank"
|
||||
prefetch={false}
|
||||
title={domain.domain_name}
|
||||
>
|
||||
{domain.domain_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_short_link}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(
|
||||
value,
|
||||
"enable_short_link",
|
||||
domain,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_email}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_email", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.enable_dns}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "enable_dns", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Switch
|
||||
defaultChecked={domain.active}
|
||||
onCheckedChange={(value) =>
|
||||
handleChangeStatus(value, "active", domain)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden truncate sm:flex">
|
||||
{timeAgo(domain.updatedAt as Date)}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex items-center gap-1">
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditDomain(domain);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p className="hidden sm:block">Edit</p>
|
||||
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
||||
</Button>
|
||||
{domain.cf_zone_id &&
|
||||
domain.cf_api_key &&
|
||||
domain.cf_email && (
|
||||
<Button
|
||||
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icons.cloudflare className="mx-0.5 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
|
||||
<DomainInfo domain={domain} />
|
||||
)} */}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don'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}</>;
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 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>
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
Add URL
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { FeatureMap, getDomainsByFeature } from "@/lib/dto/domains";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
// Get domains by feature for frontend
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const feature = url.searchParams.get("feature") || "";
|
||||
|
||||
if (!Object.keys(FeatureMap).includes(feature)) {
|
||||
return Response.json(
|
||||
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const domainList = await getDomainsByFeature(FeatureMap[feature]);
|
||||
return Response.json(domainList, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("[Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
+14
-19
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.5.1",
|
||||
"versionName": "0.6.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SectionColumnsType {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
@@ -14,7 +16,12 @@ export function FormSectionColumns({
|
||||
className,
|
||||
}: SectionColumnsType) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-1 items-center gap-x-12 gap-y-2 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="col-span-4 flex items-start gap-0.5 text-sm leading-none">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
{required && (
|
||||
|
||||
@@ -77,9 +77,7 @@ export default function EmailSidebar({
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [showEmailModal, setShowEmailModal] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>(
|
||||
siteConfig.shortDomains[0],
|
||||
);
|
||||
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
|
||||
const [deleteInput, setDeleteInput] = useState("");
|
||||
@@ -109,6 +107,13 @@ export default function EmailSidebar({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
|
||||
{ domain_name: string }[]
|
||||
>("/api/domain?feature=email", fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
});
|
||||
|
||||
if (!selectedEmailAddress && data && data.list.length > 0) {
|
||||
onSelectEmail(data.list[0].emailAddress);
|
||||
}
|
||||
@@ -595,25 +600,36 @@ export default function EmailSidebar({
|
||||
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.emailDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
@{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoadingDomains ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
|
||||
) : (
|
||||
emailDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setDomainSuffix(value);
|
||||
}}
|
||||
name="suffix"
|
||||
defaultValue={
|
||||
domainSuffix || emailDomains[0].domain_name
|
||||
}
|
||||
disabled={isEdit}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{emailDomains.map((v) => (
|
||||
<SelectItem
|
||||
key={v.domain_name}
|
||||
value={v.domain_name}
|
||||
>
|
||||
@{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { DomainFormData } from "@/lib/dto/domains";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { createDomainSchema } from "@/lib/validations/domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import { FormSectionColumns } from "../dashboard/form-section-columns";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = DomainFormData;
|
||||
|
||||
export type FormType = "add" | "edit";
|
||||
|
||||
export interface DomainFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
isShowForm: boolean;
|
||||
setShowForm: Dispatch<SetStateAction<boolean>>;
|
||||
type: FormType;
|
||||
initData?: DomainFormData | null;
|
||||
action: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function DomainForm({
|
||||
setShowForm,
|
||||
type,
|
||||
initData,
|
||||
action,
|
||||
onRefresh,
|
||||
}: DomainFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
const [currentRecordStatus, setCurrentRecordStatus] = useState(
|
||||
initData?.enable_dns || false,
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createDomainSchema),
|
||||
defaultValues: {
|
||||
id: initData?.id || "",
|
||||
domain_name: initData?.domain_name || "",
|
||||
enable_short_link: initData?.enable_short_link || false,
|
||||
enable_email: initData?.enable_email || false,
|
||||
enable_dns: initData?.enable_dns || false,
|
||||
cf_zone_id: initData?.cf_zone_id || "",
|
||||
cf_api_key: initData?.cf_api_key || "",
|
||||
cf_email: initData?.cf_email || "",
|
||||
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
|
||||
max_short_links: initData?.max_short_links || 0,
|
||||
max_email_forwards: initData?.max_email_forwards || 0,
|
||||
max_dns_records: initData?.max_dns_records || 0,
|
||||
active: initData?.active || true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateDomain(data);
|
||||
} else if (type === "edit") {
|
||||
handleUpdateDomain(data);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Created Failed!", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
// const res = await response.json();
|
||||
toast.success(`Created successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateDomain = async (data: DomainFormData) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Update Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Update successfully!`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async () => {
|
||||
if (type === "edit") {
|
||||
startDeleteTransition(async () => {
|
||||
const response = await fetch(`${action}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
domain_name: initData?.domain_name,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Delete Failed", {
|
||||
description: await response.text(),
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Success`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
|
||||
{type === "add" ? "Create" : "Edit"} Domain
|
||||
</div>
|
||||
<form className="p-4" onSubmit={onSubmit}>
|
||||
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Base
|
||||
</h2>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
|
||||
Domain Name:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("domain_name")}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.domain_name ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.domain_name.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Required. eg: example.com
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="active">
|
||||
Active:
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active ?? true}
|
||||
onCheckedChange={(value) => setValue("active", value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
|
||||
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
|
||||
Services(Optional)
|
||||
</h2>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="short_url_service">
|
||||
Short URL Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="short_url_service"
|
||||
{...register("enable_short_link")}
|
||||
defaultChecked={initData?.enable_short_link ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_short_link", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="" htmlFor="email_service">
|
||||
Email Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="email_service"
|
||||
{...register("enable_email")}
|
||||
defaultChecked={initData?.enable_email ?? false}
|
||||
onCheckedChange={(value) => setValue("enable_email", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Label className="cursor-pointer" htmlFor="dns_record_service">
|
||||
DNS Record Service:
|
||||
</Label>
|
||||
<Switch
|
||||
id="dns_record_service"
|
||||
{...register("enable_dns")}
|
||||
defaultChecked={initData?.enable_dns ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
setValue("enable_dns", value);
|
||||
setCurrentRecordStatus(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
|
||||
Cloudflare Configs(Optional)
|
||||
</h2>
|
||||
<Icons.chevronDown className="ml-auto size-4" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{!currentRecordStatus && (
|
||||
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
|
||||
<Icons.help className="size-3" /> Associate with "DNS Record
|
||||
Service" status
|
||||
</div>
|
||||
)}
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
|
||||
Zone ID:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_zone_id")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_zone_id ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_zone_id.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get zone id?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
|
||||
API Token:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_api_key")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_api_key ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_api_key.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get api token?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<Label className="mt-2.5 text-nowrap" htmlFor="email">
|
||||
Account Email:
|
||||
</Label>
|
||||
<div className="w-full sm:w-[60%]">
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 bg-neutral-50 shadow-inner"
|
||||
size={32}
|
||||
{...register("cf_email")}
|
||||
disabled={!currentRecordStatus}
|
||||
/>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.cf_email ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.cf_email.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Optional.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
href="/docs/developer/cloudflare"
|
||||
target="_blank"
|
||||
>
|
||||
How to get cloudflare account email?
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex justify-end gap-3">
|
||||
{type === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-[80px] px-0"
|
||||
onClick={() => handleDeleteDomain()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>Delete</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
Cancle
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="blue"
|
||||
disabled={isPending}
|
||||
className="w-[80px] shrink-0 px-0"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{type === "edit" ? "Update" : "Save"}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { CreateDNSRecord, RecordType } from "@/lib/cloudflare";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { RECORD_TYPE_ENUMS, TTL_ENUMS } from "@/lib/enums";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { createRecordSchema } from "@/lib/validations/record";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = CreateDNSRecord;
|
||||
@@ -77,6 +80,16 @@ export function RecordForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch the record domains
|
||||
const { data: recordDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=record",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateRecord(data);
|
||||
@@ -196,26 +209,32 @@ export function RecordForm({
|
||||
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
<FormSectionColumns title="Domain" required>
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.recordDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-full" />
|
||||
) : (
|
||||
recordDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("zone_name", value);
|
||||
setCurrentZoneName(value);
|
||||
}}
|
||||
name="zone_name"
|
||||
defaultValue={String(initData?.zone_name || "wr.do")}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-full shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Required. Select a domain.
|
||||
</p>
|
||||
|
||||
@@ -6,11 +6,12 @@ import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { EXPIRATION_ENUMS } from "@/lib/enums";
|
||||
import { generateUrlSuffix } from "@/lib/utils";
|
||||
import { fetcher, generateUrlSuffix } from "@/lib/utils";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export type FormData = ShortUrlFormData;
|
||||
|
||||
@@ -64,13 +65,22 @@ export function UrlForm({
|
||||
target: initData?.target || "",
|
||||
url: initData?.url || "",
|
||||
active: initData?.active || 1,
|
||||
prefix: initData?.prefix || siteConfig.shortDomains[0],
|
||||
prefix: initData?.prefix || "wr.do",
|
||||
visible: initData?.visible || 0,
|
||||
expiration: initData?.expiration || "-1",
|
||||
password: initData?.password || "",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: shortDomains, isLoading } = useSWR<{ domain_name: string }[]>(
|
||||
"/api/domain?feature=short",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateUrl(data);
|
||||
@@ -191,25 +201,33 @@ export function UrlForm({
|
||||
</Label>
|
||||
|
||||
<div className="relative flex w-full items-center">
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={initData?.prefix || siteConfig.shortDomains[0]}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteConfig.shortDomains.map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
|
||||
) : (
|
||||
shortDomains && (
|
||||
<Select
|
||||
onValueChange={(value: string) => {
|
||||
setValue("prefix", value);
|
||||
}}
|
||||
name="prefix"
|
||||
defaultValue={
|
||||
initData?.prefix || shortDomains[0].domain_name
|
||||
}
|
||||
disabled={type === "edit"}
|
||||
>
|
||||
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shortDomains.map((v) => (
|
||||
<SelectItem key={v.domain_name} value={v.domain_name}>
|
||||
{v.domain_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
)}
|
||||
<Input
|
||||
id="url"
|
||||
className="w-full rounded-none pl-[8px] shadow-inner"
|
||||
@@ -296,48 +314,6 @@ export function UrlForm({
|
||||
Expiration time, default for never.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
|
||||
{/* <div>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
Your Final URL:
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-white">
|
||||
{getValues("prefix")}/s/{getValues("url")}
|
||||
</p>
|
||||
</div> */}
|
||||
{/* <FormSectionColumns title="Visible">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="visible">
|
||||
Visible
|
||||
</Label>
|
||||
<Switch
|
||||
id="visible"
|
||||
{...register("visible")}
|
||||
disabled
|
||||
defaultChecked={initData?.visible === 1 || false}
|
||||
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Public or private short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
{/* <FormSectionColumns title="Active">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="active">
|
||||
Active
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active === 1 || true}
|
||||
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Enable or disable short url.
|
||||
</p>
|
||||
</FormSectionColumns> */}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -301,4 +301,33 @@ export const Icons = {
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
cloudflare: ({ ...props }: LucideProps) => (
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 -70 256 256"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<g transform="translate(0.000000, -1.000000)">
|
||||
<path
|
||||
d="M202.3569,50.394 L197.0459,48.27 C172.0849,104.434 72.7859,70.289 66.8109,86.997 C65.8149,98.283 121.0379,89.143 160.5169,91.056 C172.5559,91.639 178.5929,100.727 173.4809,115.54 L183.5499,115.571 C195.1649,79.362 232.2329,97.841 233.7819,85.891 C231.2369,78.034 191.1809,85.891 202.3569,50.394 Z"
|
||||
fill="#FFFFFF"
|
||||
></path>
|
||||
<path
|
||||
d="M176.332,109.3483 C177.925,104.0373 177.394,98.7263 174.739,95.5393 C172.083,92.3523 168.365,90.2283 163.585,89.6973 L71.17,88.6343 C70.639,88.6343 70.108,88.1033 69.577,88.1033 C69.046,87.5723 69.046,87.0413 69.577,86.5103 C70.108,85.4483 70.639,84.9163 71.701,84.9163 L164.647,83.8543 C175.801,83.3233 187.486,74.2943 191.734,63.6723 L197.046,49.8633 C197.046,49.3313 197.577,48.8003 197.046,48.2693 C191.203,21.1823 166.772,0.9993 138.091,0.9993 C111.535,0.9993 88.697,17.9953 80.73,41.8963 C75.419,38.1783 69.046,36.0533 61.61,36.5853 C48.863,37.6473 38.772,48.2693 37.178,61.0163 C36.647,64.2033 37.178,67.3903 37.71,70.5763 C16.996,71.1073 0,88.1033 0,109.3483 C0,111.4723 0,113.0663 0.531,115.1903 C0.531,116.2533 1.593,116.7843 2.125,116.7843 L172.614,116.7843 C173.676,116.7843 174.739,116.2533 174.739,115.1903 L176.332,109.3483 Z"
|
||||
fill="#F4811F"
|
||||
></path>
|
||||
<path
|
||||
d="M205.5436,49.8628 L202.8876,49.8628 C202.3566,49.8628 201.8256,50.3938 201.2946,50.9248 L197.5766,63.6718 C195.9836,68.9828 196.5146,74.2948 199.1706,77.4808 C201.8256,80.6678 205.5436,82.7918 210.3236,83.3238 L229.9756,84.3858 C230.5066,84.3858 231.0376,84.9168 231.5686,84.9168 C232.0996,85.4478 232.0996,85.9788 231.5686,86.5098 C231.0376,87.5728 230.5066,88.1038 229.4436,88.1038 L209.2616,89.1658 C198.1076,89.6968 186.4236,98.7258 182.1746,109.3478 L181.1116,114.1288 C180.5806,114.6598 181.1116,115.7218 182.1746,115.7218 L252.2826,115.7218 C253.3446,115.7218 253.8756,115.1908 253.8756,114.1288 C254.9376,109.8798 255.9996,105.0998 255.9996,100.3188 C255.9996,72.7008 233.1616,49.8628 205.5436,49.8628"
|
||||
fill="#FAAD3F"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
+7
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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"
|
||||
|
||||
@@ -48,21 +48,12 @@ Copy/paste the `.env.example` in the `.env` file:
|
||||
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
|
||||
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
|
||||
| RESEND_API_KEY | `123465` | The API key for Resend. |
|
||||
| CLOUDFLARE_ZONE | `[{"zone_id":"abc465","zone_name":"example.com"}]` | The zone info for Cloudflare. |
|
||||
| NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME | `example.com,example2.com` | The zone name for Cloudflare. |
|
||||
| CLOUDFLARE_API_KEY | `123465` | The API key for Cloudflare. |
|
||||
| CLOUDFLARE_EMAIL | `123465` | The email for Cloudflare. |
|
||||
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
|
||||
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
|
||||
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
|
||||
| NEXT_PUBLIC_SHORT_DOMAINS | `wr.do,uv.do` | The list of short domains. Separated by `,` |
|
||||
| NEXT_PUBLIC_EMAIL_DOMAINS | `wr.do,uv.do` | The list of email domains. Separated by `,` |
|
||||
|
||||
> `NEXT_PUBLIC_SHORT_DOMAINS`、`NEXT_PUBLIC_EMAIL_DOMAINS` is used to limit the short domain name and email domain name.
|
||||
|
||||
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
|
||||
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
|
||||
- How to get `CLOUDFLARE_ZONE`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`、`NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME`, see [Cloudflare Configs](/docs/developer/cloudflare).
|
||||
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
|
||||
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
|
||||
@@ -136,40 +136,7 @@ RESEND_API_KEY = re_your_resend_api_key;
|
||||
|
||||
</Steps>
|
||||
|
||||
## 3. Cloudflare Configs
|
||||
|
||||
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
|
||||
|
||||
### Add the CLOUDFLARE_ZONE Environment Variable
|
||||
|
||||
A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
|
||||
|
||||
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
|
||||
|
||||
### Add the CLOUDFLARE_API_KEY Environment Variable
|
||||
|
||||
This is the API key that you use to authenticate requests to the Cloudflare API. You can generate or find your API key in the Cloudflare dashboard under the `profile` -> `api-tokens` section.
|
||||
|
||||
> Follow [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens), and scroll down to `API Token`, the `Global API Key` should be used.
|
||||
|
||||
### Add the CLOUDFLARE_EMAIL Environment Variable
|
||||
|
||||
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
|
||||
|
||||
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
|
||||
|
||||
A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
|
||||
|
||||
In this section, you can update these variables:
|
||||
|
||||
```js title=".env"
|
||||
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
|
||||
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
|
||||
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
|
||||
CLOUDFLARE_EMAIL=user@example.com
|
||||
```
|
||||
|
||||
## 4. Email Worker Configs
|
||||
## 3. Email Worker Configs
|
||||
|
||||
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
|
||||
|
||||
@@ -187,20 +154,14 @@ Via:
|
||||
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
|
||||
```
|
||||
|
||||
## 5. Add the Bussiness Configs
|
||||
## 4. Add the Bussiness Configs
|
||||
|
||||
```js title=".env"
|
||||
# Allow anyone to sign up
|
||||
NEXT_PUBLIC_OPEN_SIGNUP=1
|
||||
|
||||
# Short domains. Separated by `,`
|
||||
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
|
||||
|
||||
# Email domains. Separated by `,`
|
||||
NEXT_PUBLIC_EMAIL_DOMAINS=wr.do,uv.do
|
||||
```
|
||||
|
||||
## 6. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
|
||||
|
||||
It's the base URL for the screenshotone API.
|
||||
|
||||
@@ -211,20 +172,36 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
|
||||
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
|
||||
```
|
||||
|
||||
## 7. Add the GITHUB_TOKEN Environment Variable
|
||||
## 6. Add the GITHUB_TOKEN Environment Variable
|
||||
|
||||
Via https://github.com/settings/tokens to get your token.
|
||||
|
||||
```js title=".env"
|
||||
GITHUB_TOKEN=
|
||||
```
|
||||
## 8. Start the Dev Server
|
||||
|
||||
## 7. Start the Dev Server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
Via [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 8. Setup System
|
||||
|
||||
#### Create the first account and Change the account's role to ADMIN
|
||||
|
||||
Follow the steps below:
|
||||
|
||||
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
|
||||
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
|
||||
- 3. Then follow the **panel guide** to config the system and add the first domain.
|
||||
|
||||
<Callout type="info">
|
||||
After change change the account's role to ADMIN, and then you can refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<strong>You must add at least one domain to start using short links, email or subdomain management functions.</strong>
|
||||
</Callout>
|
||||
|
||||
## Q & A
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Domain } from "@prisma/client";
|
||||
|
||||
import { prisma } from "../db";
|
||||
|
||||
// In-memory cache
|
||||
let domainConfigCache: Domain[] | null = null;
|
||||
let lastCacheUpdate = 0;
|
||||
const CACHE_DURATION = 60 * 1000; // Cache for 1 minute in memory
|
||||
|
||||
export const FeatureMap = {
|
||||
short: "enable_short_link",
|
||||
email: "enable_email",
|
||||
record: "enable_dns",
|
||||
};
|
||||
|
||||
export interface DomainConfig {
|
||||
domain_name: string;
|
||||
enable_short_link: boolean;
|
||||
enable_email: boolean;
|
||||
enable_dns: boolean;
|
||||
cf_zone_id: string | null;
|
||||
cf_api_key: string | null;
|
||||
cf_email: string | null;
|
||||
cf_api_key_encrypted: boolean;
|
||||
max_short_links: number | null;
|
||||
max_email_forwards: number | null;
|
||||
max_dns_records: number | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface DomainFormData extends DomainConfig {
|
||||
id?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export async function getAllDomains() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
|
||||
return domainConfigCache;
|
||||
}
|
||||
|
||||
const domains = await prisma.domain.findMany({
|
||||
// where: { active: true },
|
||||
});
|
||||
|
||||
domainConfigCache = domains;
|
||||
lastCacheUpdate = now;
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDomainsByFeature(
|
||||
feature: string,
|
||||
admin: boolean = false,
|
||||
) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (domainConfigCache && now - lastCacheUpdate < CACHE_DURATION) {
|
||||
return domainConfigCache;
|
||||
}
|
||||
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: { [feature]: true },
|
||||
select: {
|
||||
domain_name: true,
|
||||
enable_short_link: admin,
|
||||
enable_email: admin,
|
||||
enable_dns: admin,
|
||||
cf_zone_id: admin,
|
||||
cf_api_key: admin,
|
||||
cf_email: admin,
|
||||
},
|
||||
});
|
||||
return domains;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch domain config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDomain(data: DomainConfig) {
|
||||
try {
|
||||
const createdDomain = await prisma.domain.create({ data });
|
||||
return createdDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDomain(id: string, data) {
|
||||
try {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return updatedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to update domain: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDomain(domain_name: string) {
|
||||
try {
|
||||
const deletedDomain = await prisma.domain.delete({
|
||||
where: { domain_name },
|
||||
});
|
||||
return deletedDomain;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to delete domain`);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDomainConfigCache() {
|
||||
domainConfigCache = null;
|
||||
lastCacheUpdate = 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "0.5.1",
|
||||
"version": "0.6.0",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE domains
|
||||
(
|
||||
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"domain_name" TEXT NOT NULL UNIQUE,
|
||||
"enable_short_link" BOOLEAN DEFAULT FALSE,
|
||||
"enable_email" BOOLEAN DEFAULT FALSE,
|
||||
"enable_dns" BOOLEAN DEFAULT FALSE,
|
||||
"cf_zone_id" TEXT NOT NULL,
|
||||
"cf_api_key" TEXT NOT NULL,
|
||||
"cf_email" TEXT NOT NULL,
|
||||
"cf_api_key_encrypted" BOOLEAN DEFAULT FALSE,
|
||||
"max_short_links" INTEGER,
|
||||
"max_email_forwards" INTEGER,
|
||||
"max_dns_records" INTEGER,
|
||||
"active" BOOLEAN DEFAULT TRUE,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -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 |
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.5.1",
|
||||
"versionName": "0.6.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "WR.DO",
|
||||
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
|
||||
"appid": "com.wr.do",
|
||||
"versionName": "0.5.1",
|
||||
"versionName": "0.6.0",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
+36
-36
@@ -1,39 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-19T13:47:40.142Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-20T14:43:25.018Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
+101
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-3
@@ -17,9 +17,6 @@ export type SiteConfig = {
|
||||
oichat: string;
|
||||
};
|
||||
openSignup: boolean;
|
||||
shortDomains: string[];
|
||||
emailDomains: string[];
|
||||
recordDomains: string[];
|
||||
emailR2Domain: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user