Merge pull request #32 from oiov/pwd
Feats: support login with email and password
This commit is contained in:
1
.github/workflows/docker-build-push.yml
vendored
1
.github/workflows/docker-build-push.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- pwd
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN npm install -g pnpm
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
# RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
|
||||
22
README-zh.md
22
README-zh.md
@@ -9,6 +9,9 @@
|
||||
|
||||
WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱、子域名管理和开放API接口。支持自定义链接、密码保护、访问统计;提供无限制临时邮箱收发;管理多域名DNS记录;内置网站截图、元数据提取等实用API。完整的管理后台,支持用户权限控制和服务配置。
|
||||
|
||||
- 官网: [https://wr.do](https://wr.do)
|
||||
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
|
||||
|
||||
## 功能列表
|
||||
|
||||
- 🔗 **短链服务**:
|
||||
@@ -73,7 +76,7 @@ WR.DO 是一个一站式网络工具平台,集成短链服务、临时邮箱
|
||||
|
||||
### 使用 Vercel 部署
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
记得填写必要的环境变量。
|
||||
|
||||
@@ -93,9 +96,6 @@ docker compose up -d
|
||||
git clone https://github.com/oiov/wr.do
|
||||
cd wr.do
|
||||
pnpm install
|
||||
|
||||
# 在 localhost:3000 上运行
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### 初始化数据库
|
||||
@@ -105,9 +105,21 @@ pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
```bash
|
||||
# 在 localhost:3000 上运行
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 默认账号(管理员):`admin@admin.com`
|
||||
- 默认密码:`123456`
|
||||
|
||||
> 登录后请及时修改密码
|
||||
|
||||
#### 管理员初始化
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
> 此初始化引导在 v1.0.2 版本后, 不再是必要步骤
|
||||
|
||||
访问 https://localhost:3000/setup
|
||||
|
||||
## 环境变量
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -8,6 +8,9 @@
|
||||
|
||||
WR.DO is a all-in-one web utility platform featuring short links with analytics, temporary email service, subdomain management, open APIs for screenshots and metadata extraction, plus comprehensive admin dashboard.
|
||||
|
||||
- Official website: [https://wr.do](https://wr.do)
|
||||
- Demo: [https://699399.xyz](https://699399.xyz) (Account: `admin@admin.com`, Password: `123456`)
|
||||
|
||||
## Features
|
||||
|
||||
- 🔗 **Short Link Service**:
|
||||
@@ -73,7 +76,7 @@ See step by step installation tutorial at [Quick Start for Developer](https://wr
|
||||
|
||||
### Deploy with Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
@@ -103,11 +106,6 @@ pnpm install
|
||||
|
||||
copy `.env.example` to `.env` and fill in the necessary environment variables.
|
||||
|
||||
```bash
|
||||
# run on localhost:3000
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
#### Init database
|
||||
|
||||
```bash
|
||||
@@ -115,8 +113,18 @@ pnpm postinstall
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
```bash
|
||||
# run on localhost:3000
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- Default admin account:`admin@admin.com`
|
||||
- Default admin password:`123456`
|
||||
|
||||
#### Setup Admin Panel
|
||||
|
||||
> After v1.0.2, this setup guide is not needed anymore
|
||||
|
||||
Follow https://localhost:3000/setup
|
||||
|
||||
## Environment Variables
|
||||
|
||||
41
actions/update-user-password.ts
Normal file
41
actions/update-user-password.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
// import { hash } from "crypt";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashPassword } from "@/lib/utils";
|
||||
import { userPasswordSchema } from "@/lib/validations/user";
|
||||
|
||||
export type FormData = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function updateUserPassword(userId: string, data: FormData) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user || session?.user.id !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const { password } = userPasswordSchema.parse(data);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashPassword(password),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/dashboard/settings");
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
// console.log(error)
|
||||
return { status: "error" };
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default function LoginPage() {
|
||||
200 new account sign-ups each day.
|
||||
</p> */}
|
||||
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<p className="px-2 text-center text-sm text-muted-foreground">
|
||||
{t("By clicking continue, you agree to our")}{" "}
|
||||
<Link
|
||||
href="/terms"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import { SkeletonSection } from "@/components/shared/section-skeleton";
|
||||
|
||||
export default function AppConfigs({}: {}) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [loginMethodCount, setLoginMethodCount] = useState(0);
|
||||
|
||||
const { data: configs, isLoading } = useSWR<Record<string, any>>(
|
||||
"/api/admin/configs",
|
||||
@@ -33,6 +35,16 @@ export default function AppConfigs({}: {}) {
|
||||
if (!isLoading && configs?.system_notification) {
|
||||
setNotification(configs.system_notification);
|
||||
}
|
||||
// 计算登录方式数量
|
||||
if (!isLoading) {
|
||||
let count = 0;
|
||||
if (configs?.enable_google_oauth) count++;
|
||||
if (configs?.enable_github_oauth) count++;
|
||||
if (configs?.enable_liunxdo_oauth) count++;
|
||||
if (configs?.enable_resend_email_login) count++;
|
||||
if (configs?.enable_email_password_login) count++;
|
||||
setLoginMethodCount(count);
|
||||
}
|
||||
}, [configs, isLoading]);
|
||||
|
||||
const handleChange = (value: any, key: string, type: string) => {
|
||||
@@ -83,21 +95,27 @@ export default function AppConfigs({}: {}) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<div className="space-y-1 text-start leading-none">
|
||||
<p className="font-medium">{t("Login Methods")}</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("Select the login methods that users can use to log in")}
|
||||
</p>
|
||||
</div>
|
||||
<Icons.chevronDown className="ml-2 size-4" />
|
||||
|
||||
<Icons.chevronDown className="ml-auto mr-2 size-4" />
|
||||
<Badge>{loginMethodCount}</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-3 rounded-md bg-neutral-100 p-3">
|
||||
{configs && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm">GitHub OAuth</p>
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.github className="size-4" /> GitHub OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_github_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
@@ -106,7 +124,10 @@ export default function AppConfigs({}: {}) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm">Google OAuth</p>
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.google className="size-4" />
|
||||
Google OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_google_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
@@ -115,7 +136,14 @@ export default function AppConfigs({}: {}) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm">LinuxDo OAuth</p>
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<img
|
||||
src="/_static/images/linuxdo.webp"
|
||||
alt="linuxdo"
|
||||
className="size-4"
|
||||
/>
|
||||
LinuxDo OAuth
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_liunxdo_oauth}
|
||||
onCheckedChange={(v) =>
|
||||
@@ -124,7 +152,10 @@ export default function AppConfigs({}: {}) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm">{t("Resend Email")}</p>
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.resend className="size-4" />
|
||||
{t("Resend Email")}
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_resend_email_login}
|
||||
onCheckedChange={(v) =>
|
||||
@@ -132,10 +163,27 @@ export default function AppConfigs({}: {}) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="flex items-center gap-2 text-sm">
|
||||
<Icons.pwdKey className="size-4" />
|
||||
{t("Email Password")}
|
||||
</p>
|
||||
<Switch
|
||||
defaultChecked={configs.enable_email_password_login}
|
||||
onCheckedChange={(v) =>
|
||||
handleChange(
|
||||
v,
|
||||
"enable_email_password_login",
|
||||
"BOOLEAN",
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Subdomain Apply Mode")}</p>
|
||||
@@ -154,7 +202,6 @@ export default function AppConfigs({}: {}) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-3">
|
||||
<div className="space-y-1 leading-none">
|
||||
<p className="font-medium">{t("Notification")}</p>
|
||||
|
||||
@@ -154,15 +154,15 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
|
||||
<div className="grid gap-2">
|
||||
<CardTitle>{t("Subdomain List")}</CardTitle>
|
||||
<CardDescription className="hidden text-balance sm:block">
|
||||
{t("Please read the")}{" "}
|
||||
{t("Before using please read the")}{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="font-semibold text-yellow-600 after:content-['↗'] hover:underline"
|
||||
href="/docs/dns-records#legitimacy-review"
|
||||
>
|
||||
{t("legitimacy review")}
|
||||
</Link>{" "}
|
||||
{t("before using")}. {t("See")}{" "}
|
||||
</Link>
|
||||
. {t("See")}{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
className="text-blue-500 hover:underline"
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function DashboardSettingsLoading() {
|
||||
text="Manage account and website settings"
|
||||
/>
|
||||
<div className="divide-y divide-muted pb-10">
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
<SkeletonSection />
|
||||
<SkeletonSection card />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DeleteAccountSection } from "@/components/dashboard/delete-account";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
import { UserApiKeyForm } from "@/components/forms/user-api-key-form";
|
||||
import { UserNameForm } from "@/components/forms/user-name-form";
|
||||
import { UserPasswordForm } from "@/components/forms/user-password-form";
|
||||
import { UserRoleForm } from "@/components/forms/user-role-form";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
@@ -29,6 +30,7 @@ export default async function SettingsPage() {
|
||||
{user.role === "ADMIN" && (
|
||||
<UserRoleForm user={{ id: user.id, role: user.role }} />
|
||||
)}
|
||||
<UserPasswordForm user={{ id: user.id, name: user.name || "" }} />
|
||||
<UserApiKeyForm
|
||||
user={{
|
||||
id: user.id,
|
||||
|
||||
@@ -706,19 +706,21 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add URL")}</span>
|
||||
</Button>
|
||||
{action.indexOf("admin") === -1 && (
|
||||
<Button
|
||||
className="flex shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="size-4" />
|
||||
<span className="hidden sm:inline">{t("Add URL")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -225,6 +225,9 @@ function SetAdminRole({ id, email }: { id: string; email: string }) {
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
|
||||
<p className="flex items-start gap-1">
|
||||
• {t("After v1-0-2, this setup guide is not needed anymore")}.
|
||||
</p>
|
||||
<p className="flex items-start gap-1">
|
||||
•{" "}
|
||||
{t(
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function GET(req: NextRequest) {
|
||||
"enable_google_oauth",
|
||||
"enable_liunxdo_oauth",
|
||||
"enable_resend_email_login",
|
||||
"enable_email_password_login",
|
||||
]);
|
||||
|
||||
return Response.json(configs, { status: 200 });
|
||||
|
||||
45
app/api/auth/credentials/route.ts
Normal file
45
app/api/auth/credentials/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashPassword, verifyPassword } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { email, password, name } = await req.json();
|
||||
if (!email || !password) {
|
||||
return Response.json("email and password is required", { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
name: "",
|
||||
email,
|
||||
password: hashPassword(password),
|
||||
active: 1,
|
||||
role: "USER",
|
||||
team: "free",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return Response.json(newUser, { status: 200 });
|
||||
} else {
|
||||
const passwordCorrect = verifyPassword(password, user.password || "");
|
||||
if (passwordCorrect) {
|
||||
return Response.json(user, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(null, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("[Auth Error]", error);
|
||||
return Response.json(error.message || "Server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { getMultipleConfigs } from "@/lib/dto/system-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const configs = await getMultipleConfigs([
|
||||
"enable_user_registration",
|
||||
@@ -12,12 +12,14 @@ export async function GET(req: Request) {
|
||||
"enable_google_oauth",
|
||||
"enable_liunxdo_oauth",
|
||||
"enable_resend_email_login",
|
||||
"enable_email_password_login",
|
||||
]);
|
||||
return Response.json({
|
||||
google: configs.enable_google_oauth,
|
||||
github: configs.enable_github_oauth,
|
||||
linuxdo: configs.enable_liunxdo_oauth,
|
||||
resend: configs.enable_resend_email_login,
|
||||
credentials: configs.enable_email_password_login,
|
||||
registration: configs.enable_user_registration,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -58,10 +58,10 @@ export async function POST(req: Request) {
|
||||
|
||||
if (!matchedZone) {
|
||||
return Response.json(
|
||||
`No matching zone found for domain: ${record_name}`,
|
||||
`No matching zone found for domain: ${record.zone_name}`,
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Invalid domain",
|
||||
statusText: "Invalid zone name",
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export async function POST(req: Request) {
|
||||
record.type,
|
||||
record_name,
|
||||
record.content,
|
||||
record.zone_name,
|
||||
1,
|
||||
);
|
||||
if (user_record && user_record.length > 0) {
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function POST(req: Request) {
|
||||
record.type,
|
||||
record_name,
|
||||
record.content,
|
||||
record.zone_name,
|
||||
1,
|
||||
);
|
||||
if (user_record && user_record.length > 0) {
|
||||
|
||||
@@ -20,7 +20,6 @@ export async function POST(req: Request) {
|
||||
const { record: reviewRecord, userId, id } = await req.json();
|
||||
const record = {
|
||||
...reviewRecord,
|
||||
// comment: "Created by wr.do (review mode)",
|
||||
id,
|
||||
};
|
||||
|
||||
@@ -40,9 +39,11 @@ export async function POST(req: Request) {
|
||||
record,
|
||||
);
|
||||
|
||||
console.log("[data]", data);
|
||||
|
||||
if (!data.success || !data.result?.id) {
|
||||
return Response.json(data.messages, {
|
||||
status: 501,
|
||||
return Response.json(data.errors[0].message, {
|
||||
status: 503,
|
||||
});
|
||||
} else {
|
||||
const res = await updateUserRecordReview(userId, id, {
|
||||
|
||||
@@ -28,14 +28,22 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const res = await deleteDNSRecord(
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
if (active !== 3) {
|
||||
const res = await deleteDNSRecord(
|
||||
matchedZone.cf_zone_id!,
|
||||
matchedZone.cf_api_key!,
|
||||
matchedZone.cf_email!,
|
||||
record_id,
|
||||
);
|
||||
|
||||
if (res && res.result?.id) {
|
||||
if (res && res.result?.id) {
|
||||
await deleteUserRecord(user.id, record_id, zone_id, active);
|
||||
return Response.json("success", {
|
||||
status: 200,
|
||||
statusText: "success",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await deleteUserRecord(user.id, record_id, zone_id, active);
|
||||
return Response.json("success", {
|
||||
status: 200,
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const { id, data } = await req.json();
|
||||
|
||||
// TODO: update user pwd
|
||||
const res = await updateUser(id, {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
|
||||
@@ -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": "1.0.1",
|
||||
"versionName": "1.0.2",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import Github from "next-auth/providers/github";
|
||||
import Google from "next-auth/providers/google";
|
||||
import Resend from "next-auth/providers/resend";
|
||||
@@ -67,5 +68,29 @@ export default {
|
||||
},
|
||||
}),
|
||||
linuxDoProvider,
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
name: { label: "name", type: "text" },
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const res = await fetch(
|
||||
process.env.AUTH_URL + "/api/auth/credentials",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
);
|
||||
// console.log("[res]", res);
|
||||
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
1
auth.ts
1
auth.ts
@@ -53,7 +53,6 @@ export const {
|
||||
|
||||
return session;
|
||||
},
|
||||
|
||||
async jwt({ token }) {
|
||||
if (!token.sub) return token;
|
||||
|
||||
|
||||
@@ -65,9 +65,7 @@ export function RecordForm({
|
||||
const [currentRecordType, setCurrentRecordType] = useState(
|
||||
initData?.type || "CNAME",
|
||||
);
|
||||
const [currentZoneName, setCurrentZoneName] = useState(
|
||||
initData?.zone_name || "wr.do",
|
||||
);
|
||||
const [currentZoneName, setCurrentZoneName] = useState(initData?.zone_name);
|
||||
const [email, setEmail] = useState(initData?.user.email || user.email);
|
||||
const [allowedRecordTypes, setAllowedRecordTypes] = useState<string[]>([]);
|
||||
const isAdmin = action.indexOf("admin") > -1;
|
||||
@@ -83,7 +81,7 @@ export function RecordForm({
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createRecordSchema),
|
||||
defaultValues: {
|
||||
zone_name: initData?.zone_name || "wr.do",
|
||||
zone_name: initData?.zone_name,
|
||||
type: initData?.type || "CNAME",
|
||||
ttl: initData?.ttl || 1,
|
||||
proxied: initData?.proxied || false,
|
||||
@@ -121,6 +119,7 @@ export function RecordForm({
|
||||
|
||||
useEffect(() => {
|
||||
if (validDefaultDomain) {
|
||||
setValue("zone_name", validDefaultDomain);
|
||||
setCurrentZoneName(validDefaultDomain);
|
||||
}
|
||||
}, [validDefaultDomain]);
|
||||
@@ -563,7 +562,13 @@ export function RecordForm({
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{type === "edit" ? t("Update") : t("Save")}</p>
|
||||
<p>
|
||||
{type === "edit"
|
||||
? initData?.active === 2 && isAdmin
|
||||
? t("Agree")
|
||||
: t("Update")
|
||||
: t("Save")}
|
||||
</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useMemo, useTransition } from "react";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { Sparkles } from "lucide-react";
|
||||
@@ -58,7 +64,6 @@ export function UrlForm({
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createUrlSchema),
|
||||
@@ -67,7 +72,7 @@ export function UrlForm({
|
||||
target: initData?.target || "",
|
||||
url: initData?.url || "",
|
||||
active: initData?.active || 1,
|
||||
prefix: initData?.prefix || "wr.do",
|
||||
prefix: initData?.prefix || "",
|
||||
visible: initData?.visible || 0,
|
||||
expiration: initData?.expiration || "-1",
|
||||
password: initData?.password || "",
|
||||
@@ -96,6 +101,12 @@ export function UrlForm({
|
||||
return shortDomains[0].domain_name;
|
||||
}, [shortDomains, initData?.prefix]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validDefaultDomain) {
|
||||
setValue("prefix", validDefaultDomain);
|
||||
}
|
||||
}, [validDefaultDomain]);
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateUrl(data);
|
||||
|
||||
@@ -68,6 +68,7 @@ export function UserApiKeyForm({ user }: UserNameFormProps) {
|
||||
<input
|
||||
value={apiKey || "Click to generate your API key"}
|
||||
disabled
|
||||
type="password"
|
||||
className="flex h-9 flex-1 shrink-0 items-center truncate rounded-l-md border border-r-0 border-input bg-transparent px-3 py-2 text-sm"
|
||||
/>
|
||||
<CopyButton
|
||||
|
||||
@@ -10,21 +10,22 @@ import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import * as z from "zod";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import { userAuthSchema } from "@/lib/validations/auth";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { userAuthSchema, userPasswordAuthSchema } from "@/lib/validations/auth";
|
||||
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 { Skeleton } from "../ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
type FormData = z.infer<typeof userAuthSchema>;
|
||||
type FormData2 = z.infer<typeof userPasswordAuthSchema>;
|
||||
|
||||
export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
const {
|
||||
@@ -34,34 +35,64 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(userAuthSchema),
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const {
|
||||
register: register2,
|
||||
handleSubmit: handleSubmit2,
|
||||
formState: { errors: errors2 },
|
||||
} = useForm<FormData2>({
|
||||
resolver: zodResolver(userPasswordAuthSchema),
|
||||
});
|
||||
// const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [isLoading, startTransition] = React.useTransition();
|
||||
const [isGoogleLoading, setIsGoogleLoading] = React.useState<boolean>(false);
|
||||
const [isGithubLoading, setIsGithubLoading] = React.useState<boolean>(false);
|
||||
const [isLinuxDoLoading, setIsLinuxDoLoading] =
|
||||
React.useState<boolean>(false);
|
||||
const searchParams = useSearchParams();
|
||||
// const router = useRouter();
|
||||
|
||||
const t = useTranslations("Auth");
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsLoading(true);
|
||||
|
||||
const signInResult = await signIn("resend", {
|
||||
email: data.email.toLowerCase(),
|
||||
redirect: false,
|
||||
callbackUrl: searchParams?.get("from") || "/dashboard",
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!signInResult?.ok) {
|
||||
return toast.error("Something went wrong.", {
|
||||
description: "Your sign in request failed. Please try again.",
|
||||
startTransition(async () => {
|
||||
const signInResult = await signIn("resend", {
|
||||
email: data.email.toLowerCase(),
|
||||
redirect: false,
|
||||
callbackUrl: searchParams?.get("from") || "/dashboard",
|
||||
});
|
||||
}
|
||||
|
||||
return toast.success("Check your email", {
|
||||
description: "We sent you a login link. Be sure to check your spam too.",
|
||||
if (!signInResult?.ok) {
|
||||
toast.error(t("Something went wrong"), {
|
||||
description: "Your sign in request failed. Please try again.",
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("Check your email"), {
|
||||
description: `${t("We sent you a login link")}. ${t("Be sure to check your spam too")}.`,
|
||||
});
|
||||
});
|
||||
}
|
||||
async function onSubmitPwd(data: FormData2) {
|
||||
startTransition(async () => {
|
||||
const signInResult = await signIn("credentials", {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
callbackUrl: searchParams?.get("from") || "/dashboard",
|
||||
});
|
||||
|
||||
// console.log("[signInResult]", signInResult);
|
||||
|
||||
if (signInResult?.error) {
|
||||
toast.error(t("Something went wrong"), {
|
||||
description: `[${signInResult?.error}] ${t("Incorrect email or password")}.`,
|
||||
});
|
||||
} else {
|
||||
toast.success(t("Welcome back!"));
|
||||
window.location.reload();
|
||||
// router.push(searchParams?.get("from") || "/dashboard");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,12 +130,127 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const rendeResend = () =>
|
||||
loginMethod["resend"] && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors?.email && (
|
||||
<p className="px-1 text-xs text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGoogleLoading ||
|
||||
isGithubLoading
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{type === "register"
|
||||
? t("Sign Up with Email")
|
||||
: t("Sign In with Email")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
const rendeCredentials = () =>
|
||||
loginMethod["credentials"] && (
|
||||
<form onSubmit={handleSubmit2(onSubmitPwd)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
{...register2("email")}
|
||||
/>
|
||||
{errors2?.email && (
|
||||
<p className="px-1 text-xs text-red-600">
|
||||
{errors2.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
{...register2("password")}
|
||||
/>
|
||||
{errors2?.password && (
|
||||
<p className="px-1 text-xs text-red-600">
|
||||
{errors2.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGoogleLoading ||
|
||||
isGithubLoading
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{t("Sign In / Sign Up")}
|
||||
</Button>
|
||||
|
||||
<p className="rounded-md border border-dashed bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
📢 {t("Unregistered users will automatically create an account")}.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-3", className)} {...props}>
|
||||
{!loginMethod.registration && (
|
||||
<p className="rounded-md border border-dashed bg-muted p-3 text-sm text-muted-foreground">
|
||||
📢 {t("Administrator has disabled new user registration")}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loginMethod["google"] && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
onClick={() => {
|
||||
setIsGoogleLoading(true);
|
||||
signIn("google");
|
||||
@@ -123,12 +269,12 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
<Icons.google className="mr-2 size-4" />
|
||||
)}{" "}
|
||||
Google
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{loginMethod["github"] && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsGithubLoading(true);
|
||||
signIn("github");
|
||||
@@ -147,12 +293,12 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
<Icons.github className="mr-2 size-4" />
|
||||
)}{" "}
|
||||
Github
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{loginMethod["linuxdo"] && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: "outline" }))}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLinuxDoLoading(true);
|
||||
signIn("linuxdo");
|
||||
@@ -175,56 +321,29 @@ export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
|
||||
/>
|
||||
)}{" "}
|
||||
LinuxDo
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(loginMethod["google"] ||
|
||||
loginMethod["github"] ||
|
||||
loginMethod["linuxdo"]) &&
|
||||
loginMethod["resend"] &&
|
||||
(loginMethod["resend"] || loginMethod["credentials"]) &&
|
||||
rendeSeparator()}
|
||||
|
||||
{loginMethod["resend"] && (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isGoogleLoading}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors?.email && (
|
||||
<p className="px-1 text-xs text-red-600">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={cn(buttonVariants(), "mt-3")}
|
||||
disabled={
|
||||
!loginMethod.registration ||
|
||||
isLoading ||
|
||||
isGoogleLoading ||
|
||||
isGithubLoading
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<Icons.spinner className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{type === "register"
|
||||
? t("Sign Up with Email")
|
||||
: t("Sign In with Email")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{loginMethod["resend"] && loginMethod["credentials"] ? (
|
||||
<Tabs defaultValue="resend">
|
||||
<TabsList className="w-full justify-center">
|
||||
<TabsTrigger value="resend">{t("Email Code")}</TabsTrigger>
|
||||
<TabsTrigger value="password">{t("Password")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="resend">{rendeResend()}</TabsContent>
|
||||
<TabsContent value="password">{rendeCredentials()}</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<>
|
||||
{rendeResend()}
|
||||
{rendeCredentials()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
110
components/forms/user-password-form.tsx
Normal file
110
components/forms/user-password-form.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import {
|
||||
updateUserPassword,
|
||||
type FormData,
|
||||
} from "@/actions/update-user-password";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { userPasswordSchema } from "@/lib/validations/user";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SectionColumns } from "@/components/dashboard/section-columns";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
interface UserPasswordFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
}
|
||||
|
||||
export function UserPasswordForm({ user }: UserPasswordFormProps) {
|
||||
const { update } = useSession();
|
||||
const [updated, setUpdated] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateUserPasswordWithId = updateUserPassword.bind(null, user.id);
|
||||
|
||||
const t = useTranslations("Setting");
|
||||
|
||||
const checkUpdate = (value: string) => {
|
||||
setUpdated(value !== "");
|
||||
};
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(userPasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
const { status } = await updateUserPasswordWithId(data);
|
||||
|
||||
if (status !== "success") {
|
||||
toast.error("Something went wrong.", {
|
||||
description: "Your password was not updated. Please try again.",
|
||||
});
|
||||
} else {
|
||||
await update();
|
||||
setUpdated(false);
|
||||
toast.success("Your password has been updated.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<SectionColumns
|
||||
title={t("Your Password")}
|
||||
description={t("Update your password")}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="Password">
|
||||
{t("Password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="Password"
|
||||
className="flex-1"
|
||||
size={32}
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...register("password")}
|
||||
onChange={(e) => checkUpdate(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={updated ? "blue" : "disable"}
|
||||
disabled={isPending || !updated}
|
||||
className="h-9 w-[67px] shrink-0 px-0 sm:w-[130px]"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{t("Save")}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.password && (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{t("At least 6 characters, Max 32 characters")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionColumns>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ description: 选择你的部署方式
|
||||
|
||||
## 使用 Vercel 部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
## 使用 Docker Compose 部署
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Choose your deployment method
|
||||
|
||||
## Deploy with Vercel (Recommended)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo&env=DATABASE_URL&env=AUTH_SECRET&env=RESEND_API_KEY&env=NEXT_PUBLIC_EMAIL_R2_DOMAIN&env=GITHUB_TOKEN)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
Remember to fill in the necessary environment variables.
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ description: 简单介绍 WR.DO 部署所需的环境变量
|
||||
|
||||
或参考社区优秀部署文档:
|
||||
- https://linux.do/t/topic/711806
|
||||
- https://bravexist.cn/2025/06/wr.do.html
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -12,6 +12,7 @@ description: How to install the project.
|
||||
|
||||
Or read unofficial deployment tutorials:
|
||||
- https://linux.do/t/topic/711806
|
||||
- https://bravexist.cn/2025/06/wr.do.html
|
||||
</Callout>
|
||||
|
||||
<Steps>
|
||||
@@ -26,7 +27,7 @@ npx create-next-app wrdo --example "https://github.com/oiov/wr.do"
|
||||
|
||||
Or deploy with Vercel :
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Foiov%2Fwr.do)
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/oiov/wr.do.git&project-name=wrdo)
|
||||
|
||||
<Callout type="warning" twClass="mt-4">
|
||||
A good way to create your repository, but the deployment will fail because you
|
||||
|
||||
@@ -193,27 +193,12 @@ pnpm dev
|
||||
|
||||
通过浏览器访问:[http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 7. 设置系统
|
||||
- 默认账号(管理员):`admin@admin.com`
|
||||
- 默认密码:`123456`
|
||||
|
||||
#### 创建第一个账号并将账号权限更改为 ADMIN
|
||||
> 登录后请及时修改密码
|
||||
|
||||
请按以下步骤操作:
|
||||
|
||||
* 1. 通过 [http://localhost:3000/login](http://localhost:3000/login) 注册登录你的第一个账号;
|
||||
* 2. 通过 [http://localhost:3000/setup](http://localhost:3000/setup) 将账号权限更改为 ADMIN;
|
||||
* 3. 然后根据 **面板引导** 配置系统并添加第一个域名。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
<Callout type="info">
|
||||
将账号权限更改为 ADMIN 后,你可以刷新页面并访问 http://localhost:3000/admin。
|
||||
|
||||
<strong>你必须至少添加一个域名,才能使用短链接、邮件或子域名管理等功能。</strong>
|
||||
</Callout>
|
||||
|
||||
## 8. 部署
|
||||
## 7. 部署教程
|
||||
|
||||
详见:[部署指南](/docs/developer/deploy)
|
||||
|
||||
@@ -227,12 +212,4 @@ pnpm dev
|
||||
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
|
||||
```
|
||||
|
||||
将 `SSL/TLS 加密模式` 更改为 `Full` 模式。
|
||||
|
||||
### 如何修改团队计划配额?
|
||||
|
||||
通过 team.ts 文件修改:
|
||||
|
||||
```bash
|
||||
https://github.com/oiov/wr.do/tree/main/config/team.ts
|
||||
```
|
||||
将 `SSL/TLS 加密模式` 更改为 `Full` 模式。
|
||||
@@ -182,27 +182,10 @@ pnpm dev
|
||||
```
|
||||
Via [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## 7. Setup System
|
||||
- Default admin account:`admin@admin.com`
|
||||
- Default admin password:`123456`
|
||||
|
||||
#### 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 the account's role to ADMIN, then you can refresh the website and access http://localhost:3000/admin.
|
||||
|
||||
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
|
||||
</Callout>
|
||||
|
||||
## 8. Deploy
|
||||
## 7. Deploy
|
||||
|
||||
See [Deploy Guide](/docs/developer/deploy).
|
||||
|
||||
@@ -216,12 +199,4 @@ Via:
|
||||
https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
|
||||
```
|
||||
|
||||
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
|
||||
|
||||
### How can I change the team plan quota?
|
||||
|
||||
Via team.ts:
|
||||
|
||||
```bash
|
||||
https://github.com/oiov/wr.do/tree/main/config/team.ts
|
||||
```
|
||||
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
|
||||
@@ -77,9 +77,11 @@ export const createDNSRecord = async (
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error status: ${response.status}`);
|
||||
}
|
||||
// console.log("response.status", await response.json());
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`HTTP error status: ${response.status}`);
|
||||
// }
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
@@ -110,10 +112,6 @@ export const deleteDNSRecord = async (
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function updateUserRecordReview(
|
||||
|
||||
const res = await prisma.userRecord.update({
|
||||
where: {
|
||||
record_id,
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
userId,
|
||||
@@ -234,6 +234,7 @@ export async function getUserRecordByTypeNameContent(
|
||||
type: string,
|
||||
name: string,
|
||||
content: string,
|
||||
zone_name: string,
|
||||
active: number = 1,
|
||||
) {
|
||||
return await prisma.userRecord.findMany({
|
||||
@@ -242,6 +243,7 @@ export async function getUserRecordByTypeNameContent(
|
||||
type,
|
||||
// content,
|
||||
name,
|
||||
zone_name,
|
||||
active: {
|
||||
not: 3,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,10 @@ import { User, UserRole } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export interface UpdateUserForm
|
||||
extends Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> {}
|
||||
extends Omit<
|
||||
User,
|
||||
"id" | "createdAt" | "updatedAt" | "emailVerified" | "password"
|
||||
> {}
|
||||
|
||||
export const getUserByEmail = async (email: string) => {
|
||||
try {
|
||||
|
||||
21
lib/utils.ts
21
lib/utils.ts
@@ -376,3 +376,24 @@ export function extractHost(url: string): string {
|
||||
const match = url.match(regex);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* @param password 用户输入的密码
|
||||
* @param storedPassword 数据库中存储的加密密码
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
storedPassword: string,
|
||||
): boolean {
|
||||
const [salt, hash] = storedPassword.split(":");
|
||||
const hashToVerify = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return hash === hashToVerify;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export const userAuthSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const userPasswordAuthSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
image: z.string(),
|
||||
|
||||
@@ -9,4 +9,8 @@ export const userRoleSchema = z.object({
|
||||
role: z.nativeEnum(UserRole),
|
||||
});
|
||||
|
||||
export const userPasswordSchema = z.object({
|
||||
password: z.string().min(6).max(32),
|
||||
});
|
||||
|
||||
export const userApiKeySchema = z.object({});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"Domain Name": "Domain Name",
|
||||
"Please enter a valid domain name (must be hosted on Cloudflare)": "Please enter a valid domain name (must be hosted on Cloudflare)",
|
||||
"Or add later": "Or add later",
|
||||
"Submit": "Submit"
|
||||
"Submit": "Submit",
|
||||
"After v1-0-2, this setup guide is not needed anymore": "After v1.0.2, this setup guide is not needed anymore"
|
||||
},
|
||||
"List": {
|
||||
"Short URLs": "Short URLs",
|
||||
@@ -73,9 +74,8 @@
|
||||
"per page": "per page",
|
||||
"Total Subdomains": "Total Subdomains",
|
||||
"Subdomain List": "Subdomains",
|
||||
"Please read the": "Please read the",
|
||||
"Before using please read the": "Before using please read the",
|
||||
"Legitimacy review": "Legitimacy review",
|
||||
"before using": "before using",
|
||||
"See": "See",
|
||||
"examples": "examples",
|
||||
"for more usage": "for more usage",
|
||||
@@ -166,7 +166,8 @@
|
||||
"Plan name must be unique": "Plan name must be unique",
|
||||
"Record Types": "Record Types",
|
||||
"Allowed record types": "Allowed record types",
|
||||
"use `,` to separate": "use `,` to separate"
|
||||
"use `,` to separate": "use `,` to separate",
|
||||
"Agree": "Agree"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "Dashboard",
|
||||
@@ -334,7 +335,18 @@
|
||||
"Or continue with": "Or continue with",
|
||||
"Email": "Email",
|
||||
"Sign Up with Email": "Sign Up with Email",
|
||||
"Sign In with Email": "Sign In with Email"
|
||||
"Sign In with Email": "Sign In with Email",
|
||||
"Email Code": "Email",
|
||||
"Password": "Password",
|
||||
"Sign In / Sign Up": "Sign In / Sign Up",
|
||||
"Incorrect email or password": "Incorrect email or password",
|
||||
"Something went wrong": "Something went wrong",
|
||||
"Check your email": "Check your email",
|
||||
"We sent you a login link": "We sent you a login link",
|
||||
"Be sure to check your spam too": "Be sure to check your spam too",
|
||||
"Welcome back!": "Welcome back!",
|
||||
"Unregistered users will automatically create an account": "Unregistered users will automatically create an account",
|
||||
"Administrator has disabled new user registration": "Administrator has disabled new user registration"
|
||||
},
|
||||
"System": {
|
||||
"MENU": "Menu",
|
||||
@@ -466,6 +478,10 @@
|
||||
"Set system notification, this will be displayed in the header": "Set system notification, this will be displayed in the header",
|
||||
"Login Methods": "Login Methods",
|
||||
"Select the login methods that users can use to log in": "Select the login methods that users can use to log in",
|
||||
"Resend Email": "Resend Email"
|
||||
"Resend Email": "Resend Email",
|
||||
"Email Password": "Email Password",
|
||||
"Your Password": "Your Password",
|
||||
"Update your password": "Update your password",
|
||||
"At least 6 characters, Max 32 characters": "At least 6 characters, Max 32 characters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"Domain Name": "域名",
|
||||
"Please enter a valid domain name (must be hosted on Cloudflare)": "请输入有效的域名(确保已经托管到 Cloudflare)",
|
||||
"Or add later": "或稍后添加",
|
||||
"Submit": "提交"
|
||||
"Submit": "提交",
|
||||
"After v1-0-2, this setup guide is not needed anymore": "此初始化引导在 v1.0.2 版本后, 不再是必要步骤"
|
||||
},
|
||||
"List": {
|
||||
"Short URLs": "短链列表",
|
||||
@@ -73,9 +74,8 @@
|
||||
"per page": "条/页",
|
||||
"Total Subdomains": "总计",
|
||||
"Subdomain List": "子域名列表",
|
||||
"Please read the": "请阅读",
|
||||
"Before using please read the": "在使用之前请阅读",
|
||||
"legitimacy review": "链接合法性审查",
|
||||
"before using": "在使用之前",
|
||||
"See": "查看",
|
||||
"examples": "示例",
|
||||
"for more usage": "了解更多用法",
|
||||
@@ -166,7 +166,8 @@
|
||||
"Plan name must be unique": "计划名称必须唯一",
|
||||
"Record Types": "DNS 记录类型",
|
||||
"Allowed record types": "请填写标准的 DNS 记录类型",
|
||||
"use `,` to separate": "使用 `,` 分隔"
|
||||
"use `,` to separate": "使用 `,` 分隔",
|
||||
"Agree": "同意"
|
||||
},
|
||||
"Components": {
|
||||
"Dashboard": "用户面板",
|
||||
@@ -334,7 +335,18 @@
|
||||
"Or continue with": "或使用",
|
||||
"Email": "邮箱",
|
||||
"Sign Up with Email": "使用邮箱注册",
|
||||
"Sign In with Email": "使用邮箱登录"
|
||||
"Sign In with Email": "使用邮箱登录",
|
||||
"Email Code": "邮箱验证",
|
||||
"Password": "账号密码",
|
||||
"Sign In / Sign Up": "点击登录/注册",
|
||||
"Incorrect email or password": "邮箱或密码错误",
|
||||
"Something went wrong": "出错了",
|
||||
"Check your email": "检查您的邮箱",
|
||||
"We sent you a login link": "我们已向您发送登录链接",
|
||||
"Be sure to check your spam too": "请确保检查您的垃圾邮件",
|
||||
"Welcome back!": "欢迎回来!",
|
||||
"Unregistered users will automatically create an account": "未注册用户将自动创建账户",
|
||||
"Administrator has disabled new user registration": "管理员已关闭新用户注册"
|
||||
},
|
||||
"System": {
|
||||
"MENU": "菜单",
|
||||
@@ -466,6 +478,10 @@
|
||||
"Set system notification, this will be displayed in the header": "设置系统通知,将在网页顶部显示",
|
||||
"Login Methods": "登录方式",
|
||||
"Select the login methods that users can use to log in": "选择用户可以使用的登录方式",
|
||||
"Resend Email": "Resend 邮箱登录"
|
||||
"Resend Email": "Resend 邮箱登录",
|
||||
"Email Password": "账号密码登录",
|
||||
"Your Password": "账号密码",
|
||||
"Update your password": "更新您的密码",
|
||||
"At least 6 characters, Max 32 characters": "密码长度至少6位,最多32位"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wr.do",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "oiov",
|
||||
"url": "https://github.com/oiov"
|
||||
@@ -75,6 +75,7 @@
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"chalk": "^4.1.1",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
@@ -140,6 +141,7 @@
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -158,6 +158,9 @@ importers:
|
||||
'@vercel/og':
|
||||
specifier: ^0.6.2
|
||||
version: 0.6.2
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
chalk:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.2
|
||||
@@ -348,6 +351,9 @@ importers:
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.13
|
||||
version: 0.5.13(tailwindcss@3.4.6)
|
||||
'@types/bcrypt':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.16
|
||||
version: 4.17.16
|
||||
@@ -3145,6 +3151,9 @@ packages:
|
||||
'@types/acorn@4.0.6':
|
||||
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
||||
|
||||
'@types/bcrypt@5.0.2':
|
||||
resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
|
||||
|
||||
'@types/conventional-commits-parser@5.0.0':
|
||||
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
|
||||
|
||||
@@ -3808,6 +3817,10 @@ packages:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
@@ -6171,6 +6184,14 @@ packages:
|
||||
no-case@3.0.4:
|
||||
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
|
||||
|
||||
node-addon-api@8.4.0:
|
||||
resolution: {integrity: sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
node-releases@2.0.14:
|
||||
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
|
||||
|
||||
@@ -11105,6 +11126,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
|
||||
'@types/bcrypt@5.0.2':
|
||||
dependencies:
|
||||
'@types/node': 20.14.11
|
||||
|
||||
'@types/conventional-commits-parser@5.0.0':
|
||||
dependencies:
|
||||
'@types/node': 20.14.11
|
||||
@@ -11926,6 +11951,11 @@ snapshots:
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.4.0
|
||||
node-gyp-build: 4.8.4
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
binary-extensions@2.2.0: {}
|
||||
@@ -14885,6 +14915,10 @@ snapshots:
|
||||
lower-case: 2.0.2
|
||||
tslib: 2.8.1
|
||||
|
||||
node-addon-api@8.4.0: {}
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
node-releases@2.0.14: {}
|
||||
|
||||
node-releases@2.0.18: {}
|
||||
|
||||
38
prisma/migrations/20250617100233/migration.sql
Normal file
38
prisma/migrations/20250617100233/migration.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "password" TEXT;
|
||||
|
||||
INSERT INTO "system_configs"
|
||||
(
|
||||
"key",
|
||||
"value",
|
||||
"type",
|
||||
"description"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'enable_email_password_login',
|
||||
'true',
|
||||
'BOOLEAN',
|
||||
'是否启用邮箱密码登录'
|
||||
);
|
||||
|
||||
INSERT INTO "users"
|
||||
(
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
active,
|
||||
role,
|
||||
team
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'cmadvu9w874j2sczhg174pftq',
|
||||
'admin',
|
||||
'admin@admin.com',
|
||||
'c0025ebe2edf525367e859821ccac33a:95992aad7ca8dc7c51855859f7adaa6282b09439b3e138fde22aeeaa6864af0f43fdd297cc7409b24a011300c038ff4d7585f89019e7629120123ec947f62b15',
|
||||
1,
|
||||
'ADMIN',
|
||||
'free'
|
||||
);
|
||||
@@ -61,6 +61,7 @@ model User {
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
role UserRole @default(USER)
|
||||
password String?
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@ -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": "1.0.1",
|
||||
"versionName": "1.0.2",
|
||||
"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": "1.0.1",
|
||||
"versionName": "1.0.2",
|
||||
"versionCode": "1",
|
||||
"start_url": "/",
|
||||
"orientation": "portrait",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?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/api/feature</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-11T09:48:19.047Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-06-17T11:50:43.688Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user