56 Commits

Author SHA1 Message Date
oiov
cc4c6c5e96 fix(docker): prisma client package 2025-05-27 11:11:00 +08:00
oiov
3301570213 fix(docker): install prisma on runner 2025-05-27 11:01:52 +08:00
oiov
c65176e607 fix(docker): check db do not run 2025-05-27 10:50:07 +08:00
oiov
55aa93d117 feats(docker): add check db script 2025-05-27 10:06:49 +08:00
oiov
7c61b7fc44 docs(deploy): add deploy methods 2025-05-26 21:56:47 +08:00
oiov
bc7f86119c fixup type error 2025-05-26 21:39:01 +08:00
oiov
bc1490f0fd fixup 2025-05-26 21:25:54 +08:00
oiov
7bf2aa8b3c test 2025-05-26 20:43:56 +08:00
oiov
ba086b602f test 2025-05-26 20:19:04 +08:00
oiov
0d793ee31c test 2025-05-26 19:59:43 +08:00
oiov
7579be007f test 2025-05-26 19:53:02 +08:00
oiov
515e7d2719 fixup error 2025-05-26 18:05:23 +08:00
oiov
c9cfdfc07a upd 2025-05-26 17:59:28 +08:00
oiov
c589afd859 fixup url error 2025-05-26 17:50:39 +08:00
oiov
f10f8af0f6 chore 2025-05-26 17:42:37 +08:00
oiov
cbeba449ef chore: get geo and ua info without vercel 2025-05-26 17:40:01 +08:00
oiov
fa02ca000b test 2025-05-26 16:41:50 +08:00
oiov
af01d60d9b fixup 2025-05-26 16:36:36 +08:00
oiov
06f06a8a52 test 2025-05-26 16:16:48 +08:00
oiov
fc54d9e176 fixup 2025-05-26 16:06:30 +08:00
oiov
8fab48f849 test 2025-05-26 15:57:34 +08:00
oiov
69878126f6 test geo 2025-05-26 14:40:26 +08:00
oiov
0185520445 upd 2025-05-25 22:34:12 +08:00
oiov
00cb224e84 test 2025-05-25 22:20:18 +08:00
oiov
c5a932b9f1 prisma/prisma/discussions/19341 2025-05-25 22:09:44 +08:00
oiov
becc328811 emmm 2025-05-25 21:45:37 +08:00
oiov
c2ae4c78f7 fixup 2025-05-25 21:32:04 +08:00
oiov
40f2483332 chore 2025-05-25 21:15:39 +08:00
oiov
a27eb84d61 upd 2025-05-25 21:02:14 +08:00
oiov
01b80eaf9e test 2025-05-25 20:59:10 +08:00
oiov
1e713ea613 test 2025-05-25 17:16:24 +08:00
oiov
1eb7c71ff9 test 2025-05-25 17:01:06 +08:00
oiov
b9bf2733f9 change workflow 2025-05-25 15:27:34 +08:00
oiov
1e48c209f7 chore 2025-05-25 15:20:13 +08:00
oiov
fff455312e chore 2025-05-25 15:15:45 +08:00
oiov
a5626ebefe chore 2025-05-25 15:07:33 +08:00
oiov
400b1aac8d test docker build 2025-05-25 15:02:16 +08:00
oiov
872baa7933 chore 2025-05-25 12:07:52 +08:00
oiov
04b47b62ad fix(docker): change github star api 2025-05-25 11:56:17 +08:00
oiov
8894d2daae fix(docker): build error 2025-05-25 11:17:38 +08:00
oiov
3145ef884d feats: deploy with docker 2025-05-25 10:57:26 +08:00
oiov
5421285a29 bump version 0.6.2 2025-05-24 22:37:52 +08:00
oiov
59727b6be9 style: adjust url list layout 2025-05-24 22:36:31 +08:00
oiov
142cdf8b41 style(realtime): globe size 2025-05-24 21:46:55 +08:00
oiov
24ae1bc45e fix(realtime): fix charts display 2025-05-24 21:28:43 +08:00
oiov
a1cd74e90f feats: realtime globe and visits charts 2025-05-24 17:28:25 +08:00
oiov
6e8b1ccefd update domain page title 2025-05-21 19:41:17 +08:00
oiov
4d9c20d90d adjust button styles 2025-05-21 19:39:51 +08:00
oiov
91d3f06f38 add pagenation for domain list 2025-05-21 19:15:10 +08:00
oiov
a5f5312476 chore setup style for dark model 2025-05-21 18:51:25 +08:00
oiov
36254e048e adjust mobile style for domain list 2025-05-21 18:50:30 +08:00
oiov
72f76b8bca hide proxy on TXT 2025-05-21 18:40:44 +08:00
oiov
11d8f0d1d5 Merge pull request #20 from oiov/config-domains
Config domains and setup guide
2025-05-21 16:39:37 +08:00
oiov
2369885fda feat: support setup guide for admin 2025-05-21 16:16:56 +08:00
oiov
8a05fa0907 feat: support multi domain configuration and management features (database) 2025-05-20 22:46:13 +08:00
oiov
28c0cf7da7 chore url list swich color 2025-05-20 10:40:46 +08:00
101 changed files with 31361 additions and 949 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
# .dockerignore
# node_modules
# npm-debug.log
# README.md
# .env*
# .next
# .git

View File

@@ -4,9 +4,10 @@
NEXT_PUBLIC_APP_URL=http://localhost:3000
# -----------------------------------------------------------------------------
# Authentication (NextAuth.js)
# Authentication (NextAuth.js 5.0.x)
# -----------------------------------------------------------------------------
AUTH_SECRET=
AUTH_SECRET=abc123
AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -25,14 +26,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 +38,6 @@ 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
# Skip DB check and migration (only for docker), default is true. if false, will check and migrate database each time start docker compose.
SKIP_DB_CHECK=true
SKIP_DB_MIGRATION=true

64
.github/workflows/docker-build-push.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: Build and Push Docker Image to GHCR
on:
push:
branches:
- main
tags:
- 'v*.*.*'
pull_request:
branches:
- main
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/wrdo
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# 检出代码
- name: Checkout repository
uses: actions/checkout@v4
# 设置 Docker Buildx支持多平台构建
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录到 GitHub Container Registry
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 提取 Docker 镜像元数据(标签、版本等)
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=short
type=ref,event=branch,prefix=
type=ref,event=tag
# 构建并推送 Docker 镜像
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true # ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
ENVIRONMENT=${{ github.event.inputs.environment || 'production' }}
cache-from: type=gha
cache-to: type=gha,mode=max

60
Dockerfile Normal file
View File

@@ -0,0 +1,60 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache openssl
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN npm install -g pnpm
COPY . .
RUN pnpm config set registry https://registry.npmmirror.com
RUN pnpm i --frozen-lockfile
FROM base AS builder
WORKDIR /app
RUN apk add --no-cache openssl
RUN npm install -g pnpm
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm run build
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache openssl
RUN npm install -g pnpm
ENV NODE_ENV=production
ENV IS_DOCKER=true
RUN pnpm add npm-run-all dotenv prisma@5.17.0 @prisma/client@5.17.0
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Check db
COPY scripts/check-db.js /app/scripts/check-db.js
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
# CMD ["node", "server.js"]
CMD ["pnpm", "start-docker"]

View File

@@ -14,24 +14,27 @@
- <20>😀 **权限管理**:方便审核的管理员面板
- 🔒 **安全可靠**:基于 Cloudflare 强大的 DNS API
## Screenshots
## 截图预览
![screenshot](https://wr.do/_static/images/light-preview.png)
![screenshot](https://wr.do/_static/images/example_01.png)
![screenshot](https://wr.do/_static/images/example_02.png)
![screenshot](https://wr.do/_static/images/example_03.png)
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
## 快速开始
查看开发者[快速开始](https://wr.do/docs/developer/quick-start)的详细文档。
查看有关[快速开始](https://wr.do/docs/quick-start)的文档。
## 自托管教程
### 要求
- [Vercel](https://vercel.com) 账户用于部署应用
@@ -43,6 +46,23 @@
查看 [email worker](https://wr.do/docs/developer/cloudflare-email-worker) 文档用于邮件接收。
## 自部署教程
### 使用 Vercel 部署
[![Deploy with Vercel](https://vercel.com/button)](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=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
记得填写必要的环境变量。
### 使用 Docker Compose 部署
在服务器中创建一个文件夹,进入该文件夹并新建`docker-compose.yml`文件,填写必要的环境变量,然后执行:
```bash
docker compose up -d
```
## 本地开发
`.env.example` 复制为 `.env` 并填写必要的环境变量。
@@ -56,11 +76,22 @@ pnpm install
pnpm dev
```
#### 初始化数据库
```bash
pnpm postinstall
pnpm db:push
```
#### 管理员初始化
Follow https://localhost:3000/setup
## 社区群组
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
## 许可证

View File

@@ -17,20 +17,24 @@
## Screenshots
![screenshot](https://wr.do/_static/images/light-preview.png)
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
![screenshot](https://wr.do/_static/images/example_02.png)
![screenshot](https://wr.do/_static/images/example_01.png)
![screenshot](https://wr.do/_static/images/example_03.png)
## Quick Start
See usage docs about [guide](https://wr.do/docs/quick-start) for quick start.
## Self-hosted Tutorial
See step by step installation tutorial at [Quick Start for Developer](https://wr.do/docs/developer/quick-start).
### Requirements
@@ -44,34 +48,62 @@ See more docs about [developer](https://wr.do/docs/developer/installation).
See docs about [email worker](https://wr.do/docs/developer/cloudflare-email-worker).
## Local development
## Self-hosted
copy `.env.example` to `.env` and fill in the necessary environment variables.
### Deploy with Vercel
[![Deploy with Vercel](https://vercel.com/button)](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=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
Remember to fill in the necessary environment variables.
### Deploy with Docker Compose
Create a new folder and copy the `docker-compose.yml``.env` file to the folder.
```yml
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Local development
```bash
git clone https://github.com/oiov/wr.do
cd wr.do
pnpm install
```
copy `.env.example` to `.env` and fill in the necessary environment variables.
```bash
# run on localhost:3000
pnpm dev
```
## Legitimacy review
#### Init database
- To avoid abuse, applications without website content will be rejected
- To avoid domain name conflicts, please check before applying
- Completed website construction or released open source project (ready to build website for open source project)
- Political sensitivity, violence, pornography, link jumping, VPN, reverse proxy services, and other illegal or sensitive content must not appear on the website
```bash
pnpm postinstall
pnpm db:push
```
**Administrators will conduct domain name checks periodically to clean up domain names that violate the above rules, have no content, and are not open source related**
#### Setup Admin Panel
Follow https://localhost:3000/setup
## Community Group
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
![](https://wr.do/s/group)
- Discord: https://discord.gg/AHPQYuZu3m
- 微信群:
<img width="300" src="https://wr.do/s/group" />
## License

View File

@@ -25,7 +25,7 @@ export default function LoginPage() {
>
<>
<Icons.chevronLeft className="mr-2 size-4" />
Back
Back {siteConfig.openSignup ? "Home" : ""}
</>
</Link>
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">

View File

@@ -0,0 +1,362 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { User } from "@prisma/client";
import { PenLine, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner";
import useSWR, { useSWRConfig } from "swr";
import { DomainFormData } from "@/lib/dto/domains";
import { fetcher, timeAgo } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { 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-4 items-center sm:grid-cols-7">
<TableCell className="col-span-1 flex">
<Skeleton className="h-5 w-20" />
</TableCell>
<TableCell className="col-span-1 hidden sm: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 hidden sm: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-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 [searchParams, setSearchParams] = useState({
slug: "",
target: "",
userName: "",
});
const { mutate } = useSWRConfig();
const { data, isLoading } = useSWR<{
total: number;
list: DomainFormData[];
}>(
`${action}?page=${currentPage}&size=${pageSize}&target=${searchParams.target}`,
fetcher,
);
const handleRefresh = () => {
mutate(
`${action}?page=${currentPage}&size=${pageSize}&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!");
handleRefresh();
}
} else {
toast.error("Activation failed!");
}
};
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center gap-2">
<div className="flex items-center gap-1 text-lg font-bold">
<span className="text-nowrap">Total Domains:</span>
{isLoading ? (
<Skeleton className="h-6 w-16" />
) : (
<span>{data && data.total}</span>
)}
</div>
<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="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditDomain(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Domain</span>
</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 domain name..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<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, target: "" })
}
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-4 items-center text-xs sm:grid-cols-7">
<TableHead className="col-span-1 flex items-center font-bold">
Domain
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Shorten
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Email
</TableHead>
<TableHead className="col-span-1 hidden items-center text-nowrap font-bold sm:flex">
Subdomain
</TableHead>
<TableHead className="col-span-1 flex items-center text-nowrap font-bold">
Active
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
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-4 items-center sm:grid-cols-7">
<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 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_short_link}
onCheckedChange={(value) =>
handleChangeStatus(
value,
"enable_short_link",
domain,
)
}
/>
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_email}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_email", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 hidden items-center gap-1 sm:flex">
<Switch
defaultChecked={domain.enable_dns}
onCheckedChange={(value) =>
handleChangeStatus(value, "enable_dns", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Switch
disabled
defaultChecked={domain.active}
onCheckedChange={(value) =>
handleChangeStatus(value, "active", domain)
}
/>
</TableCell>
<TableCell className="col-span-1 flex items-center truncate">
{timeAgo(domain.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditDomain(domain);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">Edit</p>
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
</Button>
{domain.cf_zone_id &&
domain.cf_api_key &&
domain.cf_email && (
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="ghost"
>
<Icons.cloudflare className="mx-0.5 size-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* {isShowDomainInfo && selectedDomain?.id === domain.id && (
<DomainInfo domain={domain} />
)} */}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Title>No Domains</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any domains yet. Start creating one.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
{/* form */}
<Modal
className="max-h-[90vh] overflow-y-auto md:max-w-2xl"
showModal={isShowForm}
setShowModal={setShowForm}
>
<DomainForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditDomain}
action={action}
onRefresh={handleRefresh}
/>
</Modal>
</>
);
}
export function DomainInfo({ domain }: { domain: DomainFormData }) {
return <>{domain.domain_name}</>;
}

View File

@@ -0,0 +1,12 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardRecordsLoading() {
return (
<>
<DashboardHeader heading="Domains Management" text="" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import DomainList from "./domain-list";
export const metadata = constructMetadata({
title: "Domains - WR.DO",
description: "List and manage domains.",
});
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
return (
<>
<DashboardHeader
heading="Manage&nbsp;&nbsp;Domains"
text="List and manage domains."
link="/docs/developer/cloudflare"
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"
/>
</>
);
}

View File

@@ -4,7 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import LiveLog from "../../dashboard/urls/live-logs";
import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({
@@ -25,6 +24,7 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="short urls."
/>
<UserUrlsList
user={{
id: user.id,
@@ -35,7 +35,6 @@ export default async function DashboardPage() {
}}
action="/api/url/admin"
/>
<LiveLog admin={true} />
</>
);
}

View File

@@ -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";

View File

@@ -140,12 +140,12 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total Records:</span>{" "}
<span>Total Subdomains:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<div className="grid gap-2">
<CardTitle>DNS Records</CardTitle>
<CardTitle>Subdomains</CardTitle>
<CardDescription className="hidden text-balance sm:block">
Please read the{" "}
<Link
@@ -180,7 +180,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditRecord(null);
@@ -189,7 +189,8 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
setShowForm(!isShowForm);
}}
>
Add record
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add Record</span>
</Button>
</div>
</CardHeader>
@@ -316,7 +317,7 @@ export default function UserRecordsList({ user, action }: RecordListProps) {
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="globeLock" />
<EmptyPlaceholder.Icon name="globe" />
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any record yet. Start creating record.

View File

@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { ScrapeInfoCard } from "@/components/dashboard/dashboard-info-card";
import { StaticInfoCard } from "@/components/dashboard/dashboard-info-card";
import { DashboardHeader } from "@/components/dashboard/header";
import DashboardScrapeCharts from "./charts";
@@ -26,22 +26,19 @@ export default async function DashboardPage() {
linkText="Open API."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Screenshot"
desc="Take a screenshot of the webpage."
link="/dashboard/scrape/screenshot"
icon="camera"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Meta Info"
desc="Extract website metadata."
link="/dashboard/scrape/meta-info"
icon="globe"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to QR Code"
desc="Generate QR Code from URL."
link="/dashboard/scrape/qrcode"
@@ -49,15 +46,13 @@ export default async function DashboardPage() {
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Markdown"
desc="Convert website content to Markdown format."
link="/dashboard/scrape/markdown"
icon="heading1"
/>
<ScrapeInfoCard
userId={user.id}
<StaticInfoCard
title="Url to Text"
desc="Extract website text."
link="/dashboard/scrape/markdown"

View File

@@ -0,0 +1,543 @@
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import {
addHours,
addMinutes,
differenceInDays,
differenceInHours,
differenceInMinutes,
format,
startOfDay,
startOfHour,
startOfMinute,
} from "date-fns";
import { create } from "lodash";
import { DAILY_DIMENSION_ENUMS } from "@/lib/enums";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RealtimeChart } from "./realtime-chart";
import RealtimeLogs from "./realtime-logs";
const RealtimeGlobe = dynamic(() => import("./realtime-globe"), { ssr: false });
export interface Location {
latitude: number;
longitude: number;
count: number;
city?: string;
country?: string;
lastUpdate?: Date;
createdAt?: Date;
updatedAt?: Date;
device?: string;
browser?: string;
userUrl?: {
url: string;
target: string;
prefix: string;
};
}
interface DatabaseLocation {
latitude: number;
longitude: number;
count: number;
city: string;
country: string;
lastUpdate: Date;
updatedAt: Date;
createdAt: Date;
device?: string;
browser?: string;
userUrl?: {
url: string;
target: string;
prefix: string;
};
}
interface ChartData {
time: string;
count: number;
}
function date2unix(date: Date): number {
return Math.floor(date.getTime() / 1000);
}
export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
const mountedRef = useRef(true);
const locationDataRef = useRef<Map<string, Location>>(new Map());
const lastUpdateRef = useRef<string>();
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [timeRange, setTimeRange] = useState<string>("30min");
const [time, setTime] = useState(() => {
const now = new Date();
return {
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
endAt: date2unix(now),
};
});
const [filters, setFilters] = useState<Record<string, any>>({});
const [locations, setLocations] = useState<Location[]>([]);
const [chartData, setChartData] = useState<ChartData[]>([]);
const [stats, setStats] = useState({
totalClicks: 0,
uniqueLocations: 0,
rawRecords: 0,
lastFetch: new Date().toISOString(),
});
const createLocationKey = (lat: number, lng: number) => {
return `${Math.round(lat * 100) / 100},${Math.round(lng * 100) / 100}`;
};
const processChartData = (locations: Location[]): ChartData[] => {
// 过滤有效数据
const validLocations = locations.filter((loc) => loc.createdAt);
if (validLocations.length === 0) return [];
// 获取时间范围
const dates = validLocations.map((loc) => new Date(loc.createdAt!));
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
// 根据时间跨度选择分组策略
const totalMinutes = differenceInMinutes(maxDate, minDate);
const totalHours = differenceInHours(maxDate, minDate);
const totalDays = differenceInDays(maxDate, minDate);
let groupByFn: (date: Date) => Date;
let formatFn: (date: Date) => string;
let intervalFn: (date: Date, interval: number) => Date;
let interval: number;
// 30分钟内按1分钟分组
if (totalMinutes <= 30) {
groupByFn = startOfMinute;
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 1;
} else if (totalMinutes <= 60) {
// 1小时内按2分钟分组
groupByFn = (date) => {
const minutes = Math.floor(date.getMinutes() / 2) * 2;
const grouped = startOfMinute(date);
grouped.setMinutes(minutes);
return grouped;
};
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 2;
} else if (totalHours <= 2) {
// 2小时内按4分钟分组
groupByFn = (date) => {
const minutes = Math.floor(date.getMinutes() / 4) * 4;
const grouped = startOfMinute(date);
grouped.setMinutes(minutes);
return grouped;
};
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 4;
} else if (totalHours <= 6) {
// 6小时内按12分钟分组
groupByFn = (date) => {
const minutes = Math.floor(date.getMinutes() / 12) * 12;
const grouped = startOfMinute(date);
grouped.setMinutes(minutes);
return grouped;
};
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 12;
} else if (totalHours <= 12) {
// 12小时内按24分钟分组
groupByFn = (date) => {
const minutes = Math.floor(date.getMinutes() / 24) * 24;
const grouped = startOfMinute(date);
grouped.setMinutes(minutes);
return grouped;
};
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 24;
} else if (totalHours <= 24) {
// 24小时内按48分钟分组
groupByFn = (date) => {
const minutes = Math.floor(date.getMinutes() / 48) * 48;
const grouped = startOfMinute(date);
grouped.setMinutes(minutes);
return grouped;
};
formatFn = (date) => format(date, "MM-dd HH:mm");
intervalFn = addMinutes;
interval = 48;
} else if (totalDays <= 7) {
// 7天内按天分组
groupByFn = startOfDay;
formatFn = (date) => format(date, "MM-dd");
intervalFn = addHours;
interval = 24;
} else {
// 更长时间:按天分组
groupByFn = startOfDay;
formatFn = (date) => format(date, "MM-dd");
intervalFn = addHours;
interval = 24;
}
// 分组聚合数据
const groupedData = new Map<string, number>();
validLocations.forEach((loc) => {
const date = new Date(loc.createdAt!);
const groupedDate = groupByFn(date);
const key = groupedDate.getTime().toString();
groupedData.set(key, (groupedData.get(key) || 0) + loc.count);
});
// 填充时间间隔,确保连续性
const result: ChartData[] = [];
const startGroup = groupByFn(minDate);
const endGroup = groupByFn(maxDate);
let current = startGroup;
// 过滤掉count为0 的数据
while (current <= endGroup) {
const key = current.getTime().toString();
result.push({
time: formatFn(current),
count: groupedData.get(key) || 0,
});
current = intervalFn(current, interval);
}
return result;
};
const appendLocationData = (
newData: DatabaseLocation[],
isInitialLoad = false,
) => {
const locationMap = isInitialLoad
? new Map()
: new Map(locationDataRef.current);
let totalNewClicks = 0;
newData.forEach((item) => {
const lat = Math.round(item.latitude * 100) / 100;
const lng = Math.round(item.longitude * 100) / 100;
const key = createLocationKey(lat, lng);
const clickCount = item.count || 1;
if (locationMap.has(key)) {
const existing = locationMap.get(key)!;
existing.count += clickCount;
existing.lastUpdate = new Date(item.lastUpdate);
} else {
locationMap.set(key, {
lat,
lng,
count: clickCount,
city: item.city,
country: item.country,
lastUpdate: new Date(item.lastUpdate),
device: item.device,
browser: item.browser,
userUrl: item.userUrl,
updatedAt: item.updatedAt,
createdAt: item.createdAt,
});
}
totalNewClicks += clickCount;
});
locationDataRef.current = locationMap;
const updatedLocations = Array.from(locationMap.values());
const totalCount = updatedLocations.reduce(
(sum, loc) => sum + loc.count,
0,
);
const normalizedLocations = updatedLocations.map((loc) => ({
...loc,
count: Math.max(0.1, loc.count / Math.max(totalCount, 1)),
}));
const chartData = processChartData(updatedLocations);
return {
locations: normalizedLocations,
chartData,
totalNewClicks,
totalCount,
};
};
const getLiveLocations = async (isInitialLoad = true) => {
try {
const params = new URLSearchParams({
startAt: time.startAt.toString(),
endAt: time.endAt.toString(),
isAdmin: isAdmin ? "true" : "false",
...filters,
});
const response = await fetch(`/api/url/admin/locations?${params}`);
const result = await response.json();
if (result.error) {
// console.error("API Error:", result.error);
return;
}
const rawData: DatabaseLocation[] = result.data || [];
const {
locations: processedLocations,
chartData,
totalNewClicks,
totalCount,
} = appendLocationData(rawData, isInitialLoad);
setStats({
totalClicks: result.totalClicks || totalCount,
uniqueLocations: processedLocations.length,
rawRecords: result.rawRecords || rawData.length,
lastFetch: result.timestamp,
});
if (mountedRef.current) {
setLocations(processedLocations);
setChartData(chartData);
lastUpdateRef.current = result.timestamp;
}
if (!isInitialLoad) {
rawData.forEach((item, index) => {
setTimeout(() => {
if (mountedRef.current) {
createTrafficEvent(
item.latitude,
item.longitude,
item.city || "Unknown",
);
}
}, index * 100);
});
}
} catch (error) {
console.error("Error fetching live locations:", error);
if (mountedRef.current) {
setLocations([]);
setChartData([]);
}
}
};
const getRealtimeUpdates = async () => {
try {
const response = await fetch("/api/url/admin/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lastUpdate: lastUpdateRef.current,
startAt: time.startAt,
endAt: time.endAt,
isAdmin,
...filters,
}),
});
const result = await response.json();
if (result.error || !result.data || result.data.length === 0) {
return;
}
const {
locations: processedLocations,
chartData,
totalNewClicks,
} = appendLocationData(result.data, false);
setStats((prev) => ({
totalClicks: prev.totalClicks + totalNewClicks,
uniqueLocations: processedLocations.length,
rawRecords: prev.rawRecords + result.data.length,
lastFetch: result.timestamp,
}));
if (mountedRef.current) {
setLocations(processedLocations);
setChartData(chartData);
lastUpdateRef.current = result.timestamp;
result.data.forEach((item: DatabaseLocation, index: number) => {
setTimeout(() => {
if (mountedRef.current) {
createTrafficEvent(
item.latitude,
item.longitude,
item.city || "Unknown",
);
}
}, index * 100);
});
}
} catch (error) {
console.error("Error fetching realtime updates:", error);
}
};
const resetLocationData = () => {
locationDataRef.current.clear();
setLocations([]);
setChartData([]);
setStats({
totalClicks: 0,
uniqueLocations: 0,
rawRecords: 0,
lastFetch: new Date().toISOString(),
});
};
useEffect(() => {
if (!mountedRef.current) return;
realtimeIntervalRef.current = setInterval(() => {
if (mountedRef.current) {
getRealtimeUpdates();
}
}, 5000);
return () => {
if (realtimeIntervalRef.current) {
clearInterval(realtimeIntervalRef.current);
}
};
}, []);
useEffect(() => {
if (mountedRef.current) {
resetLocationData();
getLiveLocations(true);
}
}, [time, filters]);
useEffect(() => {
const restoreTimeRange = () => {
setTimeRange("30min");
const now = new Date();
setTime({
startAt: date2unix(new Date(now.getTime() - 30 * 60 * 1000)),
endAt: date2unix(now),
});
};
(window as any).restoreTimeRange = restoreTimeRange;
const interval = setInterval(
() => {
if (mountedRef.current) {
restoreTimeRange();
}
},
5 * 60 * 1000,
);
return () => {
clearInterval(interval);
delete (window as any).restoreTimeRange;
};
}, []);
const handleTrafficEventRef = useRef<
(lat: number, lng: number, city: string) => void
>(() => {});
const createTrafficEvent = (lat: number, lng: number, city: string) => {
if (handleTrafficEventRef.current) {
handleTrafficEventRef.current(lat, lng, city);
}
};
const handleTimeRangeChange = (value: string) => {
setTimeRange(value);
const now = new Date();
const selectedRange = DAILY_DIMENSION_ENUMS.find((e) => e.value === value);
if (!selectedRange) return;
const minutes = selectedRange.key;
const startAt = date2unix(new Date(now.getTime() - minutes * 60 * 1000));
const endAt = date2unix(now);
setTime({ startAt, endAt });
};
return (
<div className="relative w-full">
<RealtimeTimePicker
timeRange={timeRange}
setTimeRange={handleTimeRangeChange}
/>
<div className="sm:relative sm:p-4">
<RealtimeChart
className="left-0 top-0 z-10 rounded-t-none text-left sm:absolute"
chartData={chartData}
totalClicks={stats.totalClicks}
/>
<RealtimeGlobe
time={time}
filters={filters}
locations={locations}
stats={stats}
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
/>
<RealtimeLogs
className="-top-9 right-0 z-10 sm:absolute"
locations={locations}
/>
</div>
</div>
);
}
export function RealtimeTimePicker({
timeRange,
setTimeRange,
}: {
timeRange: string;
setTimeRange: (value: string) => void;
}) {
return (
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
<SelectTrigger className="rounded-b-none border-b-0 sm:w-[326px]">
<SelectValue placeholder="Select a time range" />
</SelectTrigger>
<SelectContent>
{DAILY_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem value={e.value}>
<span className="flex items-center gap-1">{e.label}</span>
</SelectItem>
{i % 2 === 0 && i !== DAILY_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { cn } from "@/lib/utils";
import StatusDot from "@/components/dashboard/status-dot";
import { Icons } from "@/components/shared/icons";
interface ChartData {
time: string;
count: number;
}
interface RealtimeChartProps {
className?: string;
chartData: ChartData[];
totalClicks: number;
}
export const RealtimeChart = ({
className,
chartData,
totalClicks,
}: RealtimeChartProps) => {
const getTickInterval = (dataLength: number) => {
if (dataLength <= 6) return 0;
if (dataLength <= 12) return 1;
if (dataLength <= 24) return Math.ceil(dataLength / 8);
return Math.ceil(dataLength / 6);
};
// console.log("chartData", chartData);
const filteredChartData = chartData.filter((item, index) => {
return item.count !== 0 || index === chartData.length - 1;
});
const tickInterval = getTickInterval(filteredChartData.length);
return (
<div className={cn(`rounded-lg border p-3 backdrop-blur-2xl`, className)}>
<div className="mb-1 flex items-center text-base font-semibold">
<StatusDot status={1} />
<h3 className="ml-2">Realtime Visits</h3>
<Icons.mousePointerClick className="ml-auto size-4 text-muted-foreground" />
</div>
<p className="mb-2 text-lg font-semibold">{totalClicks}</p>
{/* <ResponsiveContainer ></ResponsiveContainer> */}
<BarChart
width={300}
height={200}
data={filteredChartData}
margin={{ top: 10, right: 0, left: -20, bottom: 0 }}
barCategoryGap={1}
>
<XAxis
dataKey="time"
tick={{ fontSize: 12 }}
interval={tickInterval}
tickCount={Math.min(filteredChartData.length, 10)}
axisLine={false}
tickLine={false}
type="category"
scale="point"
padding={{ left: 14, right: 20 }}
tickFormatter={(value) => value.split(" ")[1]}
/>
<YAxis
domain={[0, "dataMax"]}
tickCount={5}
tick={{ fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="rounded-md border bg-primary-foreground py-2 text-primary backdrop-blur">
<p className="label px-2 text-base font-medium">{`${label}`}</p>
<p className="label px-2 text-sm">{`Visits: ${payload[0].value}`}</p>
</div>
);
}
return null;
}}
/>
<Bar
dataKey="count"
fill="#2d9af9"
radius={[1, 1, 0, 0]}
maxBarSize={20}
/>
</BarChart>
</div>
);
};

View File

@@ -0,0 +1,309 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useElementSize } from "@mantine/hooks";
import { scaleSequentialSqrt } from "d3-scale";
import { interpolateTurbo } from "d3-scale-chromatic";
import { GlobeInstance } from "globe.gl";
import { debounce } from "lodash-es";
import { Location } from "./index";
interface GlobeProps {
time: {
startAt: number;
endAt: number;
};
filters: Record<string, any>;
locations: Location[];
stats: {
totalClicks: number;
uniqueLocations: number;
rawRecords: number;
lastFetch: string;
};
setHandleTrafficEvent: (
fn: (lat: number, lng: number, city: string) => void,
) => void;
}
export default function RealtimeGlobe({
time,
filters,
locations,
stats,
}: GlobeProps) {
const globeRef = useRef<HTMLDivElement>(null);
const globeInstanceRef = useRef<any>(null);
const mountedRef = useRef(true);
let globe: GlobeInstance;
const [countries, setCountries] = useState<any>({});
const [currentLocation, setCurrentLocation] = useState<any>({});
const [hexAltitude, setHexAltitude] = useState(0.001);
const { ref: wrapperRef, width: wrapperWidth } = useElementSize();
const [isLoaded, setIsLoaded] = useState(false);
const highest =
locations.reduce((acc, curr) => Math.max(acc, curr.count), 0) || 1;
const weightColor = scaleSequentialSqrt(interpolateTurbo).domain([
0,
highest * 15,
]);
const loadGlobe = useCallback(async () => {
try {
const GlobeModule = await import("globe.gl");
const Globe = GlobeModule.default;
const { MeshPhongMaterial } = await import("three");
return { Globe, MeshPhongMaterial };
} catch (err) {
// console.error("Failed to load Globe.gl:", err);
return null;
}
}, []);
const getGlobeJSON = async () => {
try {
const response = await fetch("/countries.geojson");
const data = await response.json();
if (mountedRef.current) {
setCountries(data);
}
} catch (error) {
console.error("Error fetching globe JSON:", error);
if (mountedRef.current) {
setCountries({ type: "FeatureCollection", features: [] });
}
}
};
const getCurrentLocation = async () => {
try {
const response = await fetch("/api/location");
const data = await response.json();
if (mountedRef.current) {
setCurrentLocation(data);
}
} catch (error) {
console.error("Error fetching current location:", error);
if (mountedRef.current) {
setCurrentLocation({ latitude: 0, longitude: 0 });
}
}
};
const initGlobe = useCallback(async () => {
if (
!globeRef.current ||
!countries.features ||
globeInstanceRef.current ||
!mountedRef.current
) {
return;
}
try {
const modules = await loadGlobe();
if (!modules || !mountedRef.current) return;
const { Globe, MeshPhongMaterial } = modules;
const container = globeRef.current;
if (!container) return;
container.innerHTML = "";
const rect = container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
setTimeout(initGlobe, 200);
return;
}
globe = new Globe(container)
.width(wrapperWidth)
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
.globeOffset([0, -130])
.atmosphereColor("rgba(170, 170, 200, 0.8)")
.backgroundColor("rgba(0,0,0,0)")
.globeMaterial(
new MeshPhongMaterial({
color: "rgb(228, 228, 231)",
transparent: false,
opacity: 1,
}) as any,
);
if (countries.features && countries.features.length > 0) {
globe
.hexPolygonsData(countries.features)
.hexPolygonResolution(3)
.hexPolygonMargin(0.2)
.hexPolygonAltitude(() => hexAltitude)
.hexPolygonColor(
() => `rgba(45, 154, 249, ${Math.random() / 1.5 + 0.5})`,
);
}
globe
.hexBinResolution(4)
.hexBinPointsData(locations)
.hexBinMerge(true)
.hexBinPointWeight("count")
.hexTopColor((d: any) => {
const intensity = d.sumWeight || 0;
return weightColor(intensity);
})
.hexSideColor((d: any) => {
const intensity = d.sumWeight || 0;
return weightColor(intensity * 0.8);
})
.hexAltitude((d: any) => {
const intensity = d.sumWeight || 0;
return Math.max(0.01, intensity * 0.8);
});
globe.onGlobeReady(() => {
if (!mountedRef.current) return;
const lat = currentLocation.latitude || 0;
const lng = currentLocation.longitude || 0;
globe.pointOfView({
lat: lat,
lng: lng,
altitude: rect.width > 768 ? 2.5 : 3.5,
});
if (globe.controls()) {
globe.controls().autoRotate = true;
globe.controls().autoRotateSpeed = 0.5;
globe.controls().enableDamping = true;
globe.controls().dampingFactor = 0.1;
}
setIsLoaded(true);
});
if (globe.controls()) {
globe.controls().addEventListener(
"end",
debounce(() => {
if (!mountedRef.current || !globeInstanceRef.current) return;
try {
const distance = Math.round(globe.controls().getDistance());
let nextAlt = 0.005;
if (distance <= 300) nextAlt = 0.001;
else if (distance >= 600) nextAlt = 0.02;
if (nextAlt !== hexAltitude) {
setHexAltitude(nextAlt);
}
} catch (err) {
console.warn("Error in controls event:", err);
}
}, 200),
);
}
globeInstanceRef.current = globe;
} catch (err) {}
}, [
countries,
locations,
currentLocation,
hexAltitude,
loadGlobe,
weightColor,
]);
const cleanup = useCallback(() => {
if (globeInstanceRef.current) {
try {
if (typeof globeInstanceRef.current._destructor === "function") {
globeInstanceRef.current._destructor();
}
if (globeRef.current) {
globeRef.current.innerHTML = "";
}
} catch (err) {
console.warn("Error during cleanup:", err);
}
globeInstanceRef.current = null;
}
setIsLoaded(false);
}, []);
// useEffect(() => {
// if (globeInstanceRef.current) {
// globeInstanceRef.current.width(wrapperWidth);
// globeInstanceRef.current.height(
// wrapperWidth > 728 ? wrapperWidth * 0.8 : wrapperWidth,
// );
// }
// }, [globeInstanceRef.current, wrapperWidth, wrapperHeight]);
useEffect(() => {
if (
globeInstanceRef.current &&
mountedRef.current &&
locations.length > 0
) {
try {
globeInstanceRef.current.hexBinPointsData(locations);
} catch (err) {
console.warn("Error updating locations:", err);
}
}
}, [locations]);
useEffect(() => {
const initializeData = async () => {
if (!mountedRef.current) return;
try {
await Promise.all([getCurrentLocation(), getGlobeJSON()]);
} catch (error) {
console.error("Error initializing data:", error);
}
};
initializeData();
}, []);
useEffect(() => {
if (
countries.features &&
currentLocation &&
!globeInstanceRef.current &&
mountedRef.current
) {
const timer = setTimeout(initGlobe, 100);
return () => clearTimeout(timer);
}
}, [countries, currentLocation, initGlobe]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
cleanup();
};
}, [cleanup]);
return (
<div ref={wrapperRef} className="relative max-h-screen overflow-hidden">
<div
ref={globeRef}
className="flex justify-center"
style={{
maxWidth: `${wrapperWidth}px`, // 比较疑惑
minHeight: "100px",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "framer-motion";
import ReactCountryFlag from "react-country-flag";
import { formatTime } from "@/lib/utils";
import { Location } from "./index";
const RealtimeLogs = ({
className,
locations,
}: {
className?: string;
locations: Location[];
}) => {
const [displayedLocations, setDisplayedLocations] = useState<Location[]>([]);
const [pendingLocations, setPendingLocations] = useState<Location[]>([]);
// 生成唯一标识用于去重
const generateUniqueKey = (loc: Location): string => {
return `${loc.userUrl?.url || ""}-${loc.userUrl?.target || ""}-${loc.userUrl?.prefix || ""}-${loc.country || ""}-${loc.city || ""}-${loc.browser || ""}-${loc.device || ""}-${loc.updatedAt?.toString() || ""}`;
};
// 当外部 locations 更新时,更新预备列表
useEffect(() => {
const sortedLocations = [...locations].sort((a, b) => {
const timeA = new Date(a.updatedAt?.toString() || "").getTime() || 0;
const timeB = new Date(b.updatedAt?.toString() || "").getTime() || 0;
return timeA - timeB;
});
setPendingLocations((prev) => {
// 去重:基于多个字段判断
const newLocations = sortedLocations.filter(
(loc) =>
!prev.some((p) => generateUniqueKey(p) === generateUniqueKey(loc)) &&
!displayedLocations.some(
(d) => generateUniqueKey(d) === generateUniqueKey(loc),
),
);
return [...prev, ...newLocations];
});
}, [locations]);
// 每 2 秒从预备列表插入一条数据到显示列表
useEffect(() => {
if (pendingLocations.length === 0) return;
const interval = setInterval(() => {
setDisplayedLocations((prev) => {
if (pendingLocations.length > 0) {
const newLocation = pendingLocations[0];
// 插入新数据到顶部,限制显示列表最多 8 条
const newDisplayed = [newLocation, ...prev].slice(0, 8);
// 从预备列表移除已插入的数据
setPendingLocations((pending) => pending.slice(1));
return newDisplayed;
}
return prev;
});
}, 1500);
return () => clearInterval(interval);
}, [pendingLocations]);
// 动画配置
const itemVariants = {
initial: { opacity: 0, scale: 0.1, x: "25%", y: "25%" }, // 从中心缩放
animate: { opacity: 1, scale: 1, x: 0, y: 0 },
// exit: { opacity: 0, transition: { duration: 0.3 } }, // 渐出
};
return (
<div
className={`flex-1 overflow-y-auto ${className}`}
style={{ minHeight: "200px", maxHeight: "80vh" }}
>
<AnimatePresence initial={false}>
{displayedLocations.length > 0 &&
displayedLocations.map((loc) => (
<motion.div
key={generateUniqueKey(loc)}
variants={itemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className="mb-2 flex w-full items-center justify-start gap-3 rounded-lg border p-3 text-xs shadow-inner backdrop-blur-xl sm:w-60"
>
<ReactCountryFlag
style={{ fontSize: "16px" }}
countryCode={loc.country || "US"}
/>
<div>
<div className="flex items-center gap-1">
<Link
className="text-sm font-semibold"
href={`https://${loc.userUrl?.prefix}/s/${loc.userUrl?.url}`}
target="_blank"
>
{loc.userUrl?.url}
</Link>
<span className="font-semibold">·</span>
<span className="text-muted-foreground">
{formatTime(loc.updatedAt?.toString() || "")}
</span>
</div>
{loc.browser && loc.browser !== "Unknown" && (
<div className="mt-1 line-clamp-1 break-words font-medium text-muted-foreground">
{loc.browser}
{loc.device &&
loc.device !== "Unknown" &&
`${", "}${loc.device}`}
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
};
export default RealtimeLogs;

View File

@@ -30,6 +30,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -235,22 +236,26 @@ export function DailyPVUVChart({
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
key={e.value}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>

View File

@@ -11,6 +11,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -58,23 +59,26 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem
className=""
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
key={e.value}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem
disabled={
e.key > TeamPlanQuota[user.team!].SL_AnalyticsRetention
}
value={e.value}
>
<span className="flex items-center gap-1">
{e.label}
{e.key >
TeamPlanQuota[user.team!].SL_AnalyticsRetention && (
<Icons.crown className="size-3" />
)}
</span>
</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>

View File

@@ -4,8 +4,6 @@ import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "../../../../components/shared/api-reference";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -36,12 +34,6 @@ export default async function DashboardPage() {
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</>
);
}

View File

@@ -16,13 +16,6 @@ import {
timeAgo,
} from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
@@ -35,6 +28,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
@@ -43,16 +37,16 @@ import {
} from "@/components/ui/tooltip";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
import ApiReference from "@/components/shared/api-reference";
import { CopyButton } from "@/components/shared/copy-button";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
import {
LinkInfoPreviewer,
LinkPreviewer,
} from "@/components/shared/link-previewer";
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
import QRCodeEditor from "@/components/shared/qr";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlMetaInfo from "./meta";
export interface UrlListProps {
@@ -145,18 +139,268 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}
};
const renderList = () => (
<div className="rounded-lg border p-4">
<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 className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<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, target: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<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, userName: "" })
}
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-3 items-center sm:grid-cols-11">
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Created
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<div className="border-b" key={short.id}>
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
</TableCell>
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={short.target}
formatUrl={removeUrlSuffix(short.target)}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Switch
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
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>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="mx-0.5 size-4" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="outline"
onClick={() => {
setSelectedUrl(short);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</div>
);
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total URLs:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<CardTitle>Short URLs</CardTitle>
)}
<Tabs defaultValue="Links">
<div className="mb-4 flex items-center justify-between gap-2">
<TabsList>
<TabsTrigger value="Links">Links</TabsTrigger>
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
@@ -170,7 +414,7 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
)}
</Button>
<Button
className="w-[120px] shrink-0 gap-1"
className="flex shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditUrl(null);
@@ -179,265 +423,25 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
setShowForm(!isShowForm);
}}
>
Add url
<Icons.add className="size-4" />
<span className="hidden sm:inline">Add URL</span>
</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>
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<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, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<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, userName: "" })
}
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-3 items-center sm:grid-cols-11">
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Created
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
<TableColumnSekleton />
</>
) : data && data.list && data.list.length ? (
data.list.map((short) => (
<div className="border-b" key={short.id}>
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Link
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
href={`https://${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
className={cn(
"size-[25px]",
"duration-250 transition-all group-hover:opacity-100",
)}
/>
{short.password && (
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
)}
</TableCell>
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
<LinkInfoPreviewer
apiKey={user.apiKey ?? ""}
url={short.target}
formatUrl={removeUrlSuffix(short.target)}
/>
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger className="truncate">
{short.userName ?? "Anonymous"}
</TooltipTrigger>
<TooltipContent>
{short.userName ?? "Anonymous"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
<Switch
className="data-[state=checked]:bg-blue-500"
defaultChecked={short.active === 1}
onCheckedChange={(value) =>
handleChangeStatu(value, short.id || "")
}
/>
</TableCell>
<TableCell className="col-span-1 hidden sm:flex">
{expirationTime(short.expiration, short.updatedAt)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt as Date)}
</TableCell>
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
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>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant={"outline"}
onClick={() => {
setSelectedUrl(short);
setShowQrcode(!isShowQrcode);
}}
>
<Icons.qrcode className="mx-0.5 size-4" />
</Button>
<Button
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
size="sm"
variant="outline"
onClick={() => {
setSelectedUrl(short);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="link" />
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any url yet. Start creating url.
</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>
<TabsContent className="space-y-3" value="Links">
{renderList()}
<LiveLog admin={action.indexOf("admin") > -1} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe isAdmin={action.indexOf("admin") > -1} />
</TabsContent>
</Tabs>
{/* QR code editor */}
<Modal

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { User } from "@prisma/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import ApiReference from "@/components/shared/api-reference";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export function Wrapper({
user,
}: {
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
}) {
const [tab, setTab] = useState("Links");
return (
<Tabs
value={tab}
onChangeCapture={(e) => console.log(e)}
defaultValue={tab}
>
<TabsList>
<TabsTrigger value="Links" onClick={() => setTab("Links")}>
Links
</TabsTrigger>
<TabsTrigger value="Realtime" onClick={() => setTab("Realtime")}>
Realtime
</TabsTrigger>
</TabsList>
)
<TabsContent className="space-y-3" value="Links">
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,344 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { cn, removeUrlSuffix } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
import { FormSectionColumns } from "@/components/dashboard/form-section-columns";
import { Icons } from "@/components/shared/icons";
export default function StepGuide({
user,
}: {
user: { id: string; email: string };
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [direction, setDirection] = useState(0);
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
const steps = [
{
id: 1,
title: "Set up an administrator",
description:
"Begin by entering your website URL or selecting an example site to reimagine your website with modern themes.",
component: () => <SetAdminRole id={user.id} email={user.email} />,
},
{
id: 2,
title: "Add the first domain",
description:
"Check out your reimagined site and click to Migrate & Download.",
component: () => <AddDomain onNextStep={goToNextStep} />,
},
{
id: 3,
title: "Congrats on completing setup 🎉",
description:
"Navigate to your GitHub dashboard where you'll manage your repository and project files.",
component: () => <Congrats />,
},
];
const goToNextStep = () => {
if (currentStep < steps.length) {
setDirection(1);
setCurrentStep(currentStep + 1);
if (!completedSteps.includes(currentStep)) {
setCompletedSteps([...completedSteps, currentStep]);
}
} else if (currentStep === steps.length) {
router.push("/admin");
}
};
const goToPreviousStep = () => {
if (currentStep > 1) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
const currentStepData =
steps.find((step) => step.id === currentStep) || steps[0];
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 100 : -100,
opacity: 0,
}),
};
return (
<Modal className="md:max-w-2xl">
<div className="w-full px-4 py-2 md:px-8 md:py-4">
<div className="mb-6 mt-3 flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold">Admin Setup Guide</h2>
<div className="flex items-center gap-2 rounded-full bg-muted/50 px-3 py-1.5 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
{currentStep}
</span>
<span className="text-muted-foreground">of</span>
<span>{steps.length}</span>
</div>
</div>
{/* Content area */}
<div className="relative w-full rounded-lg">
<AnimatePresence custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex flex-col justify-center gap-6"
>
<div className="flex h-full w-full flex-col">
<div className="mb-2 flex items-center gap-1 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{currentStep}
</span>
<motion.h3
className="text-base font-semibold"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.title}
</motion.h3>
</div>
<motion.div
className="h-full"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{currentStepData.component()}
</motion.div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
<motion.div
className="mt-auto flex justify-between px-4 pb-4 pt-3 md:px-8 md:pb-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<button
onClick={goToPreviousStep}
disabled={currentStep === 1}
className={cn(
"flex items-center gap-2 rounded-md px-4 py-2 transition-colors",
currentStep === 1
? "cursor-not-allowed bg-muted text-muted-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</button>
<button
onClick={goToNextStep}
// disabled={currentStep === steps.length}
className={cn(
"flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90",
)}
>
{currentStep === steps.length ? "🚀 Start" : "Next"}
<ChevronRight className="h-4 w-4" />
</button>
</motion.div>
</Modal>
);
}
function SetAdminRole({ id, email }: { id: string; email: string }) {
const [isPending, startTransition] = useTransition();
const [isAdmin, setIsAdmin] = useState(false);
const handleSetAdmin = async () => {
startTransition(async () => {
const res = await fetch("/api/setup");
if (res.ok) {
setIsAdmin(true);
}
});
};
const ReadyBadge = (
<Badge className="text-xs font-semibold" variant="green">
<Icons.check className="mr-1 size-3" />
Ready
</Badge>
);
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Allow Sign Up:
</span>
{siteConfig.openSignup ? ReadyBadge : <Skeleton className="h-4 w-12" />}
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-muted-foreground">
Set {email} as ADMIN:
</span>
{isAdmin ? (
ReadyBadge
) : (
<Button
className=""
variant={"outline"}
size={"sm"}
onClick={handleSetAdmin}
disabled={isPending}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Active Now
</Button>
)}
</div>
<div className="rounded-md border border-dashed p-2 text-xs text-muted-foreground">
<p className="flex items-start gap-1">
📢 Only by becoming an administrator can one access the admin panel
and add domain names.
</p>
<p className="my-1">
📢 Administrators can set all user permissions, allocate quotas, view
and edit all resources (short links, subdomains, email), etc.
</p>
<p>
📢 Via{" "}
<a
className="text-blue-500"
target="_blank"
href="/docs/developer/quick-start"
>
quick start
</a>{" "}
docs to get more information.
</p>
</div>
</div>
);
}
function AddDomain({ onNextStep }: { onNextStep: () => void }) {
const [isPending, startTransition] = useTransition();
const [domain, setDomain] = useState("");
const handleCreateDomain = async () => {
if (!domain) {
toast.warning("Domain name cannot be empty");
return;
}
startTransition(async () => {
const res = await fetch("/api/admin/domain", {
method: "POST",
body: JSON.stringify({
data: {
domain_name: removeUrlSuffix(domain),
enable_short_link: true,
enable_email: true,
enable_dns: true,
cf_zone_id: "",
cf_api_key: "",
cf_email: "",
cf_api_key_encrypted: false,
max_short_links: 0,
max_email_forwards: 0,
max_dns_records: 0,
active: true,
},
}),
});
if (res.ok) {
onNextStep();
} else {
toast.error("Created Failed!", {
description: await res.text(),
});
}
});
};
return (
<div className="flex flex-col gap-4 rounded-lg bg-neutral-50 p-4 dark:bg-neutral-900">
<FormSectionColumns title="Domain Name">
<div className="flex w-full flex-col items-start justify-between gap-2">
<Label className="sr-only" htmlFor="domain_name">
Domain Name
</Label>
<div className="w-full">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner dark:bg-neutral-600"
size={32}
placeholder="example.com"
onChange={(e) => setDomain(e.target.value)}
/>
</div>
<p className="text-xs text-muted-foreground">
Please enter a valid domain name (must be hosted on Cloudflare).
</p>
</div>
<div className="mt-2 flex w-full items-center justify-end gap-3">
<Button
className="text-xs text-muted-foreground"
variant={"ghost"}
size={"sm"}
onClick={onNextStep}
>
Or add later
</Button>
<Button
className="flex items-center gap-1"
size={"sm"}
variant={"blue"}
disabled={isPending}
onClick={handleCreateDomain}
>
{isPending && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
Submit
</Button>
</div>
</FormSectionColumns>
</div>
);
}
function Congrats() {
return <></>;
}

View File

@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader
heading="Manage&nbsp;Short&nbsp;URLs"
text="List and manage short urls."
/>
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-[400px] w-full rounded-lg" />
</>
);
}

View File

@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { getAllUsersCount } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import SetupGuide from "./guide";
export const metadata = constructMetadata({
title: "Setup Guide",
description: "Setup Guide",
});
export default async function SetupPage() {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
if (user.role === "ADMIN") redirect("/admin");
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
return <SetupGuide user={{ id: user.id, email: user.email! }} />;
}
return redirect("/admin");
}

View File

@@ -0,0 +1,145 @@
import { NextRequest } from "next/server";
import {
createDomain,
deleteDomain,
getAllDomains,
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 });
}
const url = new URL(req.url);
const page = url.searchParams.get("page");
const size = url.searchParams.get("size");
const target = url.searchParams.get("target") || "";
const data = await getAllDomains(
Number(page || "1"),
Number(size || "10"),
target,
);
return Response.json(data, { 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,
});
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,
});
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);
return Response.json(deletedDomain, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

34
app/api/domain/route.ts Normal file
View File

@@ -0,0 +1,34 @@
import { NextRequest } from "next/server";
import { FeatureMap, getDomainsByFeatureClient } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export const dynamic = "force-dynamic";
// Get domains by feature for frontend
export async function GET(req: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const url = new URL(req.url);
const feature = url.searchParams.get("feature") || "";
if (!Object.keys(FeatureMap).includes(feature)) {
return Response.json(
"Invalid feature parameter. Use 'short', 'email', or 'record'.",
{
status: 400,
},
);
}
const domainList = await getDomainsByFeatureClient(FeatureMap[feature]);
return Response.json(domainList, { status: 200 });
} catch (error) {
console.error("[Error]", error);
return Response.json(error.message || "Server error", { status: 500 });
}
}

27
app/api/location/route.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { geolocation } from "@vercel/functions";
interface CurrentLocation {
latitude: number;
longitude: number;
}
export async function GET(req: NextRequest) {
try {
const geo = geolocation(req);
const location: CurrentLocation = {
latitude: Number(geo.latitude || "0"),
longitude: Number(geo.longitude || "0"),
};
return NextResponse.json(location, { status: 200 });
} catch (error) {
console.error("Error fetching location:", error);
// Fallback to default coordinates
return NextResponse.json(
{ latitude: 40.7128, longitude: -74.006 },
{ status: 200 },
);
}
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -6,31 +5,27 @@ import {
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret, parseZones } from "@/lib/utils";
import { generateSecret } from "@/lib/utils";
export async function POST(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "API key、zone iD and email are required",
statusText: "Please add at least one domain",
});
}
const { total } = await getUserRecordCount(user.id);
if (
user.role !== "ADMIN" &&
total >= TeamPlanQuota[user.team].RC_NewRecords
) {
if (total >= TeamPlanQuota[user.team].RC_NewRecords) {
return Response.json("Your records have reached the free limit.", {
status: 409,
});
@@ -49,7 +44,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -85,22 +80,22 @@ export async function POST(req: Request) {
}
const data = await createDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
if (!data.success || !data.result?.id) {
console.log("[data]", data);
// console.log("[data]", data);
return Response.json(data.messages, {
status: 501,
});
} else {
const res = await createUserRecord(user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { TeamPlanQuota } from "@/config/team";
import { createDNSRecord } from "@/lib/cloudflare";
import {
@@ -6,10 +5,11 @@ import {
getUserRecordByTypeNameContent,
getUserRecordCount,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { generateSecret, parseZones } from "@/lib/utils";
import { generateSecret } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -22,13 +22,11 @@ export async function POST(req: Request) {
});
}
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json("API key、zone iD and email are required", {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "API key、zone iD and email are required",
statusText: "Please add at least one domain",
});
}
@@ -60,7 +58,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -96,9 +94,9 @@ export async function POST(req: Request) {
}
const data = await createDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
record,
);
@@ -110,8 +108,8 @@ export async function POST(req: Request) {
} else {
const res = await createUserRecord(target_user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,

View File

@@ -1,9 +1,8 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -24,20 +23,15 @@ export async function POST(req: Request) {
});
}
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "Missing required configuration",
},
);
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "Please add at least one domain",
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -48,9 +42,9 @@ export async function POST(req: Request) {
// force delete
await deleteUserRecord(userId, record_id, zone_id, active);
await deleteDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);

View File

@@ -1,6 +1,5 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus, getUserByEmail } from "@/lib/dto/user";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {

View File

@@ -1,9 +1,8 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -16,16 +15,11 @@ export async function POST(req: Request) {
});
}
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "Missing required configuration",
},
{ status: 401, statusText: "Missing required configuration" },
);
}
@@ -44,7 +38,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -61,9 +55,9 @@ export async function POST(req: Request) {
}
const data = await updateDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
{ ...record, name: record_name },
);
@@ -80,8 +74,8 @@ export async function POST(req: Request) {
const res = await updateUserRecord(userId, {
record_id: data.result.id,
zone_id: matchedZone.zone_id,
zone_name: matchedZone.zone_name,
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,

View File

@@ -1,9 +1,9 @@
import { env } from "@/env.mjs";
import { deleteDNSRecord } from "@/lib/cloudflare";
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
export async function POST(req: Request) {
try {
@@ -12,20 +12,15 @@ export async function POST(req: Request) {
const { record_id, zone_id, active } = await req.json();
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
return Response.json(
"API key, zone configuration, and email are required",
{
status: 400,
statusText: "API key, zone configuration, and email are required",
},
);
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json("Please add at least one domain", {
status: 400,
statusText: "Please add at least one domain",
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -34,9 +29,9 @@ export async function POST(req: Request) {
}
const res = await deleteDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id!,
matchedZone.cf_api_key!,
matchedZone.cf_email!,
record_id,
);

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";

View File

@@ -1,13 +1,12 @@
import { env } from "@/env.mjs";
import { updateDNSRecord } from "@/lib/cloudflare";
import {
updateUserRecord,
updateUserRecordState,
} from "@/lib/dto/cloudflare-dns-record";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { checkUserStatus } from "@/lib/dto/user";
import { reservedDomains } from "@/lib/enums";
import { getCurrentUser } from "@/lib/session";
import { parseZones } from "@/lib/utils";
// Update DNS record
export async function POST(req: Request) {
@@ -15,10 +14,8 @@ export async function POST(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
@@ -40,7 +37,7 @@ export async function POST(req: Request) {
let matchedZone;
for (const zone of zones) {
if (record.zone_name === zone.zone_name) {
if (record.zone_name === zone.domain_name) {
matchedZone = zone;
break;
}
@@ -64,14 +61,13 @@ export async function POST(req: Request) {
}
const data = await updateDNSRecord(
matchedZone.zone_id,
CLOUDFLARE_API_KEY,
CLOUDFLARE_EMAIL,
matchedZone.cf_zone_id,
matchedZone.cf_api_key,
matchedZone.cf_email,
recordId,
{ ...record, name: record_name },
);
console.log("[updateDNSRecord]", data);
if (!data.success || !data.result?.id) {
return Response.json(
data.errors?.[0]?.message || "Failed to update DNS record",
@@ -81,8 +77,8 @@ export async function POST(req: Request) {
const res = await updateUserRecord(user.id, {
record_id: data.result.id,
zone_id: matchedZone.zone_id, // Use matched zone_id
zone_name: matchedZone.zone_name, // Use matched zone_name
zone_id: matchedZone.cf_zone_id,
zone_name: matchedZone.domain_name,
name: data.result.name,
type: data.result.type,
content: data.result.content,
@@ -118,10 +114,8 @@ export async function PUT(req: Request) {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const { CLOUDFLARE_ZONE, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env;
const zones = parseZones(CLOUDFLARE_ZONE || "[]");
if (!zones.length || !CLOUDFLARE_API_KEY || !CLOUDFLARE_EMAIL) {
const zones = await getDomainsByFeature("enable_dns", true);
if (!zones.length) {
return Response.json(
"API key, zone configuration, and email are required",
{ status: 401, statusText: "Missing required configuration" },
@@ -136,7 +130,7 @@ export async function PUT(req: Request) {
});
}
const matchedZone = zones.find((zone) => zone.zone_id === zone_id);
const matchedZone = zones.find((zone) => zone.cf_zone_id === zone_id);
if (!matchedZone) {
return Response.json(`Invalid or unsupported zone_id: ${zone_id}`, {
status: 400,
@@ -153,9 +147,9 @@ export async function PUT(req: Request) {
isTargetAccessible = target_res.status === 200;
} catch (fetchError) {
isTargetAccessible = false;
console.log(
`[Fetch Error] Failed to access target ${target}: ${fetchError}`,
);
// console.log(
// `[Fetch Error] Failed to access target ${target}: ${fetchError}`,
// );
}
const res = await updateUserRecordState(

31
app/api/setup/route.ts Normal file
View File

@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import {
checkUserStatus,
getAllUsersCount,
setFirstUserAsAdmin,
} from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(req: Request) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const count = await getAllUsersCount();
if (count === 1 && user.role === "USER") {
const res = await setFirstUserAsAdmin(user.id);
if (res) {
return Response.json({ admin: res.role === "ADMIN" }, { status: 201 });
}
return Response.json({ admin: false }, { status: 400 });
}
return redirect("/admin");
} catch (error) {
return Response.json(error?.statusText || error, {
status: error.status || 500,
});
}
}

View File

@@ -1,4 +1,5 @@
import { TeamPlanQuota } from "@/config/team";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
@@ -23,6 +24,18 @@ export async function POST(req: Request) {
const { target, url, prefix, visible, active, expiration, password } =
createUrlSchema.parse(data);
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 400,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",

View File

@@ -0,0 +1,278 @@
import { NextRequest, NextResponse } from "next/server";
import { create } from "lodash";
import { prisma } from "@/lib/db";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";
export async function GET(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const searchParams = request.nextUrl.searchParams;
const isAdmin = searchParams.get("isAdmin");
if (isAdmin === "true") {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
}
const startAtParam = searchParams.get("startAt");
const endAtParam = searchParams.get("endAt");
const country = searchParams.get("country");
let startDate: Date;
let endDate: Date;
if (startAtParam && endAtParam) {
startDate = new Date(parseInt(startAtParam) * 1000);
endDate = new Date(parseInt(endAtParam) * 1000);
} else {
endDate = new Date();
startDate = new Date(Date.now() - 30 * 60 * 1000); // 30分钟前
}
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error("Invalid startAt or endAt parameters");
}
const whereClause: any = {
...(isAdmin === "true" ? {} : { userUrl: { userId: user.id } }),
createdAt: {
gte: startDate,
lte: endDate,
},
latitude: {
not: null,
},
longitude: {
not: null,
},
};
if (country && country !== "") {
whereClause.country = country;
}
const rawData = await prisma.urlMeta.findMany({
where: whereClause,
select: {
latitude: true,
longitude: true,
click: true,
city: true,
country: true,
device: true,
browser: true,
createdAt: true,
updatedAt: true,
userUrl: {
select: {
url: true,
target: true,
prefix: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: 2000,
});
// console.log("Raw data fetched:", rawData.length, "records");
const locationMap = new Map<
string,
{
latitude: number;
longitude: number;
count: number;
city: string;
country: string;
lastUpdate: Date;
updatedAt: Date;
createdAt: Date;
device: string;
browser: string;
userUrl: {
url: string;
target: string;
prefix: string;
};
}
>();
rawData.forEach((item) => {
if (item.latitude && item.longitude) {
const lat = Math.round(Number(item.latitude) * 100) / 100;
const lng = Math.round(Number(item.longitude) * 100) / 100;
const key = `${lat},${lng}`;
if (locationMap.has(key)) {
const existing = locationMap.get(key)!;
existing.count += item.click || 1;
if (item.updatedAt > existing.lastUpdate) {
existing.lastUpdate = item.updatedAt;
existing.city = item.city || existing.city;
existing.country = item.country || existing.country;
}
} else {
locationMap.set(key, {
latitude: lat,
longitude: lng,
count: item.click || 1,
city: item.city || "",
country: item.country || "",
lastUpdate: item.updatedAt,
updatedAt: item.updatedAt,
createdAt: item.createdAt,
device: item.device || "",
browser: item.browser || "",
userUrl: item.userUrl,
});
}
}
});
const aggregatedData = Array.from(locationMap.values()).sort(
(a, b) => b.count - a.count,
);
// .slice(0, 500);
const totalClicks = aggregatedData.reduce(
(sum, item) => sum + item.count,
0,
);
const uniqueLocations = aggregatedData.length;
// console.log(
// `Fetched ${rawData.length} records, aggregated to ${uniqueLocations} locations, total clicks: ${totalClicks}`,
// );
return NextResponse.json({
data: aggregatedData,
total: uniqueLocations,
totalClicks,
rawRecords: rawData.length,
timeRange: {
startAt: startDate.toISOString(),
endAt: endDate.toISOString(),
},
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Error fetching location data:", error);
return NextResponse.json(
{
data: [],
total: 0,
totalClicks: 0,
rawRecords: 0,
error:
error instanceof Error
? error.message
: "Failed to fetch location data",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
// finally {
// await prisma.$disconnect();
// }
}
// POST endpoint remains the same
export async function POST(request: NextRequest) {
try {
const user = checkUserStatus(await getCurrentUser());
if (user instanceof Response) return user;
const body = await request.json();
const { lastUpdate, isAdmin } = body;
if (isAdmin) {
if (user.role !== "ADMIN") {
return Response.json("Unauthorized", {
status: 401,
statusText: "Unauthorized",
});
}
}
const sinceDate = lastUpdate
? new Date(lastUpdate)
: new Date(Date.now() - 5000);
// console.log("lastUpdate", lastUpdate, sinceDate);
if (isNaN(sinceDate.getTime())) {
throw new Error("Invalid lastUpdate parameter");
}
const whereClause: any = {
...(isAdmin ? {} : { userUrl: { userId: user.id } }),
createdAt: {
gt: sinceDate,
},
latitude: {
not: null,
},
longitude: {
not: null,
},
};
const newData = await prisma.urlMeta.findMany({
where: whereClause,
select: {
latitude: true,
longitude: true,
click: true,
city: true,
country: true,
device: true,
browser: true,
createdAt: true,
updatedAt: true,
userUrl: {
select: {
url: true,
target: true,
prefix: true,
},
},
},
orderBy: { updatedAt: "desc" },
take: 1000,
});
// console.log("Realtime updates fetched:", newData.length, "records");
return NextResponse.json({
data: newData,
count: newData.length,
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error("Error fetching realtime updates:", error);
return NextResponse.json(
{
data: [],
count: 0,
error:
error instanceof Error
? error.message
: "Failed to fetch realtime updates",
timestamp: new Date().toISOString(),
},
{ status: 500 },
);
}
// finally {
// await prisma.$disconnect();
// }
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env.mjs";
import { getUserShortUrls } from "@/lib/dto/short-urls";
import { checkUserStatus } from "@/lib/dto/user";
import { getCurrentUser } from "@/lib/session";

View File

@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
import { siteConfig } from "@/config/site";
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserEmail, deleteUserEmailByAddress } from "@/lib/dto/email";
import { reservedAddressSuffix } from "@/lib/enums";
import { restrictByTimeRange } from "@/lib/team";
@@ -53,7 +54,12 @@ export async function POST(req: NextRequest) {
status: 400,
});
}
if (!siteConfig.emailDomains.includes(suffix)) {
const zones = await getDomainsByFeature("enable_email", true);
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(suffix)
) {
return NextResponse.json("Invalid email suffix address", { status: 400 });
}

View File

@@ -1,14 +1,11 @@
import { ipAddress } from "@vercel/functions";
import { getIpInfo } from "@/lib/utils";
import { extractRealIP, getIpInfo } from "@/lib/geo";
export async function GET(req: Request) {
try {
const data = getIpInfo(req);
const ip = ipAddress(req);
const data = await getIpInfo(req);
return Response.json({
ip,
ip: data.ip,
city: data.city,
region: data.region,
country: data.country,

View File

@@ -3,7 +3,8 @@ import TurndownService from "turndown";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
@@ -70,9 +71,9 @@ export async function GET(req: Request) {
const markdown = turndownService.turndown(mainContent || "");
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "markdown",
referer: stats.referer,
city: stats.city,

View File

@@ -2,7 +2,8 @@ import cheerio from "cheerio";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink, removeUrlSuffix } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink, removeUrlSuffix } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
@@ -84,9 +85,9 @@ export async function GET(req: Request) {
$("meta[name='author']").attr("content") ||
$("meta[property='author']").attr("content");
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "meta-info",
referer: stats.referer,
city: stats.city,

View File

@@ -2,9 +2,10 @@ import { ImageResponse } from "@vercel/og";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo } from "@/lib/geo";
import { WRDO_QR_LOGO } from "@/lib/qr/constants";
import { QRCodeSVG } from "@/lib/qr/utils";
import { getIpInfo, getSearchParams } from "@/lib/utils";
import { getSearchParams } from "@/lib/utils";
import { getQRCodeQuerySchema } from "@/lib/validations/qr";
const CORS_HEADERS = {
@@ -41,9 +42,9 @@ export async function GET(req: Request) {
);
}
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "qrcode",
referer: stats.referer,
city: stats.city,

View File

@@ -1,7 +1,8 @@
import { env } from "@/env.mjs";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 60;
@@ -69,9 +70,9 @@ export async function GET(req: Request) {
);
}
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "screenshot",
referer: stats.referer,
city: stats.city,

View File

@@ -2,7 +2,8 @@ import cheerio from "cheerio";
import { checkApiKey } from "@/lib/dto/api-key";
import { createScrapeMeta } from "@/lib/dto/scrape";
import { getIpInfo, isLink } from "@/lib/utils";
import { getIpInfo } from "@/lib/geo";
import { isLink } from "@/lib/utils";
export const revalidate = 600;
export const dynamic = "force-dynamic";
@@ -63,9 +64,9 @@ export async function GET(req: Request) {
$("style").remove();
const text = $("body").text().trim();
const stats = getIpInfo(req);
const stats = await getIpInfo(req);
await createScrapeMeta({
ip: stats.ip,
ip: stats.ip || "::1",
type: "text",
referer: stats.referer,
city: stats.city,

View File

@@ -1,5 +1,6 @@
import { TeamPlanQuota } from "@/config/team";
import { checkApiKey } from "@/lib/dto/api-key";
import { getDomainsByFeature } from "@/lib/dto/domains";
import { createUserShortUrl } from "@/lib/dto/short-urls";
import { restrictByTimeRange } from "@/lib/team";
import { createUrlSchema } from "@/lib/validations/url";
@@ -41,6 +42,17 @@ export async function POST(req: Request) {
});
}
const zones = await getDomainsByFeature("enable_short_link");
if (
!zones.length ||
!zones.map((zone) => zone.domain_name).includes(prefix)
) {
return Response.json("Invalid domain", {
status: 409,
statusText: "Invalid domain",
});
}
const res = await createUserShortUrl({
userId: user.id,
userName: user.name || "Anonymous",

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -22,6 +22,7 @@ export const {
handlers: { GET, POST },
auth,
} = NextAuth({
trustHost: true, // TODO: Test with docker
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {

View File

@@ -26,6 +26,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
@@ -122,10 +123,13 @@ export function InteractiveBarChart() {
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
{DATE_DIMENSION_ENUMS.map((e, i) => (
<div key={e.value}>
<SelectItem value={e.value}>{e.label}</SelectItem>
{i % 2 === 0 && i !== DATE_DIMENSION_ENUMS.length - 1 && (
<SelectSeparator />
)}
</div>
))}
</SelectContent>
</Select>

View File

@@ -145,14 +145,12 @@ export function HeroCard({
);
}
export async function ScrapeInfoCard({
userId,
export async function StaticInfoCard({
title,
desc,
link,
icon = "users",
}: {
userId: string;
title: string;
desc?: string;
link: string;

View File

@@ -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 && (

View File

@@ -77,9 +77,7 @@ export default function EmailSidebar({
const [isEdit, setIsEdit] = useState(false);
const [showEmailModal, setShowEmailModal] = useState(false);
const [isPending, startTransition] = useTransition();
const [domainSuffix, setDomainSuffix] = useState<string | null>(
siteConfig.shortDomains[0],
);
const [domainSuffix, setDomainSuffix] = useState<string | null>("wr.do");
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [emailToDelete, setEmailToDelete] = useState<string | null>(null);
const [deleteInput, setDeleteInput] = useState("");
@@ -109,6 +107,13 @@ export default function EmailSidebar({
},
);
const { data: emailDomains, isLoading: isLoadingDomains } = useSWR<
{ domain_name: string }[]
>("/api/domain?feature=email", fetcher, {
revalidateOnFocus: false,
dedupingInterval: 10000,
});
if (!selectedEmailAddress && data && data.list.length > 0) {
onSelectEmail(data.list[0].emailAddress);
}
@@ -595,25 +600,38 @@ export default function EmailSidebar({
isEdit ? selectedEmailAddress?.split("@")[0] || "" : ""
}
/>
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || siteConfig.emailDomains[0]}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.emailDomains.map((v) => (
<SelectItem key={v} value={v}>
@{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoadingDomains ? (
<Skeleton className="h-9 w-1/3 rounded-none border-x-0 shadow-inner" />
) : (
<Select
onValueChange={(value: string) => {
setDomainSuffix(value);
}}
name="suffix"
defaultValue={domainSuffix || "wr.do"}
disabled={isEdit}
>
<SelectTrigger className="w-1/3 rounded-none border-x-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{emailDomains && emailDomains.length > 0 ? (
emailDomains.map((v) => (
<SelectItem
key={v.domain_name}
value={v.domain_name}
>
@{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Button
className="rounded-l-none"
type="button"

View File

@@ -0,0 +1,418 @@
"use client";
import { Dispatch, SetStateAction, useState, useTransition } from "react";
import Link from "next/link";
import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { siteConfig } from "@/config/site";
import { DomainFormData } from "@/lib/dto/domains";
import { EXPIRATION_ENUMS } from "@/lib/enums";
import { generateUrlSuffix } from "@/lib/utils";
import { createDomainSchema } from "@/lib/validations/domain";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Icons } from "@/components/shared/icons";
import { FormSectionColumns } from "../dashboard/form-section-columns";
import { Badge } from "../ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
export type FormData = DomainFormData;
export type FormType = "add" | "edit";
export interface DomainFormProps {
user: Pick<User, "id" | "name">;
isShowForm: boolean;
setShowForm: Dispatch<SetStateAction<boolean>>;
type: FormType;
initData?: DomainFormData | null;
action: string;
onRefresh: () => void;
}
export function DomainForm({
setShowForm,
type,
initData,
action,
onRefresh,
}: DomainFormProps) {
const [isPending, startTransition] = useTransition();
const [isDeleting, startDeleteTransition] = useTransition();
const [currentRecordStatus, setCurrentRecordStatus] = useState(
initData?.enable_dns || false,
);
const {
handleSubmit,
register,
formState: { errors },
getValues,
setValue,
} = useForm<FormData>({
resolver: zodResolver(createDomainSchema),
defaultValues: {
id: initData?.id || "",
domain_name: initData?.domain_name || "",
enable_short_link: initData?.enable_short_link || false,
enable_email: initData?.enable_email || false,
enable_dns: initData?.enable_dns || false,
cf_zone_id: initData?.cf_zone_id || "",
cf_api_key: initData?.cf_api_key || "",
cf_email: initData?.cf_email || "",
cf_api_key_encrypted: initData?.cf_api_key_encrypted || false,
max_short_links: initData?.max_short_links || 0,
max_email_forwards: initData?.max_email_forwards || 0,
max_dns_records: initData?.max_dns_records || 0,
active: initData?.active || true,
},
});
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateDomain(data);
} else if (type === "edit") {
handleUpdateDomain(data);
}
});
const handleCreateDomain = async (data: DomainFormData) => {
startTransition(async () => {
const response = await fetch(`${action}`, {
method: "POST",
body: JSON.stringify({
data,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Created Failed!", {
description: await response.text(),
});
} else {
// const res = await response.json();
toast.success(`Created successfully!`);
setShowForm(false);
onRefresh();
}
});
};
const handleUpdateDomain = async (data: DomainFormData) => {
startTransition(async () => {
if (type === "edit") {
const response = await fetch(`${action}`, {
method: "PUT",
body: JSON.stringify(data),
});
if (!response.ok || response.status !== 200) {
toast.error("Update Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Update successfully!`);
setShowForm(false);
onRefresh();
}
}
});
};
const handleDeleteDomain = async () => {
if (type === "edit") {
startDeleteTransition(async () => {
const response = await fetch(`${action}`, {
method: "DELETE",
body: JSON.stringify({
domain_name: initData?.domain_name,
}),
});
if (!response.ok || response.status !== 200) {
toast.error("Delete Failed", {
description: await response.text(),
});
} else {
await response.json();
toast.success(`Success`);
setShowForm(false);
onRefresh();
}
});
}
};
return (
<div>
<div className="rounded-t-lg bg-muted px-4 py-2 text-lg font-semibold">
{type === "add" ? "Create" : "Edit"} Domain
</div>
<form className="p-4" onSubmit={onSubmit}>
<div className="relative flex flex-col items-center justify-start gap-0 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Base
</h2>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="domain_name">
Domain Name:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("domain_name")}
/>
<div className="flex flex-col justify-between p-1">
{errors?.domain_name ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.domain_name.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. eg: example.com
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="active">
Active:
</Label>
<Switch
id="active"
{...register("active")}
defaultChecked={initData?.active ?? true}
onCheckedChange={(value) => setValue("active", value)}
disabled
/>
</div>
</div>
<div className="relative mt-2 flex flex-col items-center justify-start gap-4 rounded-md bg-neutral-100 p-4 pt-10 dark:bg-neutral-800">
<h2 className="absolute left-2 top-2 text-xs font-semibold text-neutral-400">
Services(Optional)
</h2>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="short_url_service">
Short URL Service:
</Label>
<Switch
id="short_url_service"
{...register("enable_short_link")}
defaultChecked={initData?.enable_short_link ?? false}
onCheckedChange={(value) => setValue("enable_short_link", value)}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<Label className="" htmlFor="email_service">
Email Service:
</Label>
<Switch
id="email_service"
{...register("enable_email")}
defaultChecked={initData?.enable_email ?? false}
onCheckedChange={(value) => setValue("enable_email", value)}
/>
</div>
<div className="flex w-full items-center justify-between gap-2">
<Label className="cursor-pointer" htmlFor="dns_record_service">
DNS Record Service:
</Label>
<Switch
id="dns_record_service"
{...register("enable_dns")}
defaultChecked={initData?.enable_dns ?? false}
onCheckedChange={(value) => {
setValue("enable_dns", value);
setCurrentRecordStatus(value);
}}
/>
</div>
</div>
<Collapsible className="relative mt-2 rounded-md bg-neutral-100 p-4 dark:bg-neutral-800">
<CollapsibleTrigger className="flex w-full items-center justify-between">
<h2 className="absolute left-2 top-4 text-xs font-semibold text-neutral-400">
Cloudflare Configs(Optional)
</h2>
<Icons.chevronDown className="ml-auto size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
{!currentRecordStatus && (
<div className="mt-3 flex items-center gap-1 rounded bg-neutral-200 p-2 text-xs dark:bg-neutral-700">
<Icons.help className="size-3" /> Associate with "DNS Record
Service" status
</div>
)}
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="zone_id">
Zone ID:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_zone_id")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_zone_id ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_zone_id.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get zone id?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="api-key">
API Token:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_api_key")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_api_key ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_api_key.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get api token?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
<FormSectionColumns title="">
<div className="flex w-full items-start justify-between gap-2">
<Label className="mt-2.5 text-nowrap" htmlFor="email">
Account Email:
</Label>
<div className="w-full sm:w-[60%]">
<Input
id="target"
className="flex-1 bg-neutral-50 shadow-inner"
size={32}
{...register("cf_email")}
disabled={!currentRecordStatus}
/>
<div className="flex flex-col justify-between p-1">
{errors?.cf_email ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.cf_email.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Optional.{" "}
<Link
className="text-blue-500"
href="/docs/developer/cloudflare"
target="_blank"
>
How to get cloudflare account email?
</Link>
</p>
)}
</div>
</div>
</div>
</FormSectionColumns>
</CollapsibleContent>
</Collapsible>
{/* Action buttons */}
<div className="mt-3 flex justify-end gap-3">
{type === "edit" && (
<Button
type="button"
variant="destructive"
className="mr-auto w-[80px] px-0"
onClick={() => handleDeleteDomain()}
disabled={isDeleting}
>
{isDeleting ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Delete</p>
)}
</Button>
)}
<Button
type="reset"
variant="outline"
className="w-[80px] px-0"
onClick={() => setShowForm(false)}
>
Cancle
</Button>
<Button
type="submit"
variant="blue"
disabled={isPending}
className="w-[80px] shrink-0 px-0"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>{type === "edit" ? "Update" : "Save"}</p>
)}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -5,11 +5,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { User } from "@prisma/client";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import { siteConfig } from "@/config/site";
import { CreateDNSRecord, RecordType } from "@/lib/cloudflare";
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
import { RECORD_TYPE_ENUMS, TTL_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import { createRecordSchema } from "@/lib/validations/record";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -24,6 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Skeleton } from "../ui/skeleton";
import { Switch } from "../ui/switch";
export type FormData = CreateDNSRecord;
@@ -77,6 +80,16 @@ export function RecordForm({
},
});
// Fetch the record domains
const { data: recordDomains, isLoading } = useSWR<{ domain_name: string }[]>(
"/api/domain?feature=record",
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateRecord(data);
@@ -196,26 +209,36 @@ export function RecordForm({
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Domain" required>
<Select
onValueChange={(value: string) => {
setValue("zone_name", value);
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.recordDomains.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? (
<Skeleton className="h-9 w-full" />
) : (
<Select
onValueChange={(value: string) => {
setValue("zone_name", value);
setCurrentZoneName(value);
}}
name="zone_name"
defaultValue={String(initData?.zone_name || "wr.do")}
disabled={type === "edit"}
>
<SelectTrigger className="w-full shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{recordDomains && recordDomains.length > 0 ? (
recordDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<p className="p-1 text-[13px] text-muted-foreground">
Required. Select a domain.
</p>
@@ -356,21 +379,23 @@ export function RecordForm({
Optional. Time To Live.
</p>
</FormSectionColumns>
<FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch
id="proxied"
{...register("proxied")}
onCheckedChange={(value) => setValue("proxied", value)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status.
</p>
</FormSectionColumns>
{["A", "CNAME"].includes(currentRecordType) && (
<FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch
id="proxied"
{...register("proxied")}
onCheckedChange={(value) => setValue("proxied", value)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Proxy status.
</p>
</FormSectionColumns>
)}
</div>
{/* Action buttons */}

View File

@@ -6,11 +6,12 @@ import { User } from "@prisma/client";
import { Sparkles } from "lucide-react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import useSWR from "swr";
import { siteConfig } from "@/config/site";
import { ShortUrlFormData } from "@/lib/dto/short-urls";
import { EXPIRATION_ENUMS } from "@/lib/enums";
import { generateUrlSuffix } from "@/lib/utils";
import { fetcher, generateUrlSuffix } from "@/lib/utils";
import { createUrlSchema } from "@/lib/validations/url";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -25,7 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch";
import { Skeleton } from "../ui/skeleton";
export type FormData = ShortUrlFormData;
@@ -64,13 +65,22 @@ export function UrlForm({
target: initData?.target || "",
url: initData?.url || "",
active: initData?.active || 1,
prefix: initData?.prefix || siteConfig.shortDomains[0],
prefix: initData?.prefix || "wr.do",
visible: initData?.visible || 0,
expiration: initData?.expiration || "-1",
password: initData?.password || "",
},
});
const { data: shortDomains, isLoading } = useSWR<{ domain_name: string }[]>(
"/api/domain?feature=short",
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
},
);
const onSubmit = handleSubmit((data) => {
if (type === "add") {
handleCreateUrl(data);
@@ -191,25 +201,35 @@ export function UrlForm({
</Label>
<div className="relative flex w-full items-center">
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || siteConfig.shortDomains[0]}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{siteConfig.shortDomains.map((v) => (
<SelectItem key={v} value={v}>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
{isLoading ? (
<Skeleton className="h-9 w-1/3 rounded-r-none border-r-0 shadow-inner" />
) : (
<Select
onValueChange={(value: string) => {
setValue("prefix", value);
}}
name="prefix"
defaultValue={initData?.prefix || "wr.do"}
disabled={type === "edit"}
>
<SelectTrigger className="w-1/3 rounded-r-none border-r-0 shadow-inner">
<SelectValue placeholder="Select a domain" />
</SelectTrigger>
<SelectContent>
{shortDomains && shortDomains.length > 0 ? (
shortDomains.map((v) => (
<SelectItem key={v.domain_name} value={v.domain_name}>
{v.domain_name}
</SelectItem>
))
) : (
<Button className="w-full" variant="ghost">
No domain
</Button>
)}
</SelectContent>
</Select>
)}
<Input
id="url"
className="w-full rounded-none pl-[8px] shadow-inner"
@@ -296,48 +316,6 @@ export function UrlForm({
Expiration time, default for never.
</p>
</FormSectionColumns>
{/* <div>
<p className="text-sm text-gray-700 dark:text-white">
Your Final URL:
</p>
<p className="text-sm text-gray-700 dark:text-white">
{getValues("prefix")}/s/{getValues("url")}
</p>
</div> */}
{/* <FormSectionColumns title="Visible">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="visible">
Visible
</Label>
<Switch
id="visible"
{...register("visible")}
disabled
defaultChecked={initData?.visible === 1 || false}
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Public or private short url.
</p>
</FormSectionColumns> */}
{/* <FormSectionColumns title="Active">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="active">
Active
</Label>
<Switch
id="active"
{...register("active")}
defaultChecked={initData?.active === 1 || true}
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Enable or disable short url.
</p>
</FormSectionColumns> */}
</div>
{/* Action buttons */}

View File

@@ -10,27 +10,19 @@ interface GitHubResponse {
}
async function getGitHubStars(owner: string, repo: string) {
const githubToken = process.env.GITHUB_TOKEN;
if (!githubToken) {
throw new Error("GitHub token is not configured");
}
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: {
Accept: "application/vnd.github.v3+json",
Authorization: `token ${githubToken}`,
"User-Agent": "NextJS-App",
const res = await fetch(
`https://wr.do/api/github?owner=${owner}&repo=${repo}`,
{
next: { revalidate: 3600 },
},
next: { revalidate: 3600 },
});
);
if (!res.ok) {
throw new Error("Failed to fetch GitHub stars");
}
const data: GitHubResponse = await res.json();
return data.stargazers_count;
const data = await res.json();
return data.stars;
}
interface Props {

View File

@@ -301,4 +301,33 @@ export const Icons = {
</g>
</svg>
),
cloudflare: ({ ...props }: LucideProps) => (
<svg
width="800px"
height="800px"
viewBox="0 -70 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
preserveAspectRatio="xMidYMid"
{...props}
>
<g>
<g transform="translate(0.000000, -1.000000)">
<path
d="M202.3569,50.394 L197.0459,48.27 C172.0849,104.434 72.7859,70.289 66.8109,86.997 C65.8149,98.283 121.0379,89.143 160.5169,91.056 C172.5559,91.639 178.5929,100.727 173.4809,115.54 L183.5499,115.571 C195.1649,79.362 232.2329,97.841 233.7819,85.891 C231.2369,78.034 191.1809,85.891 202.3569,50.394 Z"
fill="#FFFFFF"
></path>
<path
d="M176.332,109.3483 C177.925,104.0373 177.394,98.7263 174.739,95.5393 C172.083,92.3523 168.365,90.2283 163.585,89.6973 L71.17,88.6343 C70.639,88.6343 70.108,88.1033 69.577,88.1033 C69.046,87.5723 69.046,87.0413 69.577,86.5103 C70.108,85.4483 70.639,84.9163 71.701,84.9163 L164.647,83.8543 C175.801,83.3233 187.486,74.2943 191.734,63.6723 L197.046,49.8633 C197.046,49.3313 197.577,48.8003 197.046,48.2693 C191.203,21.1823 166.772,0.9993 138.091,0.9993 C111.535,0.9993 88.697,17.9953 80.73,41.8963 C75.419,38.1783 69.046,36.0533 61.61,36.5853 C48.863,37.6473 38.772,48.2693 37.178,61.0163 C36.647,64.2033 37.178,67.3903 37.71,70.5763 C16.996,71.1073 0,88.1033 0,109.3483 C0,111.4723 0,113.0663 0.531,115.1903 C0.531,116.2533 1.593,116.7843 2.125,116.7843 L172.614,116.7843 C173.676,116.7843 174.739,116.2533 174.739,115.1903 L176.332,109.3483 Z"
fill="#F4811F"
></path>
<path
d="M205.5436,49.8628 L202.8876,49.8628 C202.3566,49.8628 201.8256,50.3938 201.2946,50.9248 L197.5766,63.6718 C195.9836,68.9828 196.5146,74.2948 199.1706,77.4808 C201.8256,80.6678 205.5436,82.7918 210.3236,83.3238 L229.9756,84.3858 C230.5066,84.3858 231.0376,84.9168 231.5686,84.9168 C232.0996,85.4478 232.0996,85.9788 231.5686,86.5098 C231.0376,87.5728 230.5066,88.1038 229.4436,88.1038 L209.2616,89.1658 C198.1076,89.6968 186.4236,98.7258 182.1746,109.3478 L181.1116,114.1288 C180.5806,114.6598 181.1116,115.7218 182.1746,115.7218 L252.2826,115.7218 C253.3446,115.7218 253.8756,115.1908 253.8756,114.1288 C254.9376,109.8798 255.9996,105.0998 255.9996,100.3188 C255.9996,72.7008 233.1616,49.8628 205.5436,49.8628"
fill="#FAAD3F"
></path>
</g>
</g>
</svg>
),
};

View File

@@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground",
blue: "bg-blue-500 hover:bg-blue-600 border-transparent text-white",
green: "bg-green-500 hover:bg-green-600 border-transparent text-white",
},
},
defaultVariants: {

View File

@@ -11,7 +11,7 @@ export const sidebarLinks: SidebarNavItem[] = [
{ href: "/dashboard", icon: "dashboard", title: "Dashboard" },
{ href: "/dashboard/urls", icon: "link", title: "Short Urls" },
{ href: "/emails", icon: "mail", title: "Emails" },
{ href: "/dashboard/records", icon: "globeLock", title: "DNS Records" },
{ href: "/dashboard/records", icon: "globe", title: "DNS Records" },
{ href: "/chat", icon: "messages", title: "WRoom" },
],
},
@@ -54,6 +54,12 @@ export const sidebarLinks: SidebarNavItem[] = [
title: "Admin Panel",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/domains",
icon: "globeLock",
title: "Domains",
authorizeOnly: UserRole.ADMIN,
},
{
href: "/admin/users",
icon: "users",

View File

@@ -1,12 +1,9 @@
import { SidebarNavItem, SiteConfig } from "types";
import { env } from "@/env.mjs";
const site_url = env.NEXT_PUBLIC_APP_URL;
const site_url = env.NEXT_PUBLIC_APP_URL || "http://localhost:3030";
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,
};

View File

@@ -5,6 +5,49 @@ description: How to config the cloudflare api.
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
## Overview
Administrators can manage domain configurations at `/admin/domains`, including adding, deleting, and modifying domains.
![](/_static/docs/domain-form.png)
### Domain Configuration Form
The `Short URL Service` and `Email Service` require no additional configuration and can be activated to enable short URL and email functionalities (email requires worker forwarding setup).
To enable the `DNS Record Service`, you must complete the `Cloudflare Configs(Optional)` form with the following fields:
- Zone ID
- API Token
- Email
These fields are used to configure the Cloudflare API. If your domain is hosted through Cloudflare, you can find these details in the Cloudflare dashboard.
### Zone ID
The unique identifier for a domain hosted on Cloudflare, located at:
https://dash.cloudflare.com/[account_id]/[zone_name]
### API Token
Visit https://dash.cloudflare.com/profile/api-tokens, and find the Global API Key under the API Tokens section.
### Email
Email for registering a Cloudflare account
<Callout type="info">
You can manage domains hosted under different Cloudflare accounts,
provided the API Token and Email are sourced from the same account.
</Callout>
---
# This section is Deprecated since version v0.6.0
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
In this section, you can update these variables:
```js title=".env"

View File

@@ -0,0 +1,48 @@
---
title: Deploy Guide
description: Choose your deployment method
---
## Deploy with Vercel (Recommended)
[![Deploy with Vercel](https://vercel.com/button)](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=NEXT_PUBLIC_OPEN_SIGNUP&env=GITHUB_TOKEN)
Remember to fill in the necessary environment variables.
## Deploy with Docker Compose
<Callout type="warning" twClass="mt-4">
Please create your database instance before deployment.
Set `SKIP_DB_CHECK` and `SKIP_DB_MIGRATION` to `false` in the `.env` file, this will start the database check and migration.
</Callout>
Create a new folder and copy the `docker-compose.yml`、`.env` file to the folder.
```bash
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```
## Deploy with Docker Compose (Local DB)
Create a new folder and copy the `docker-compose-localdb.yml`、`.env` file to the folder.
```bash
- wrdo
| - docker-compose.yml
| - .env
```
Fill in the environment variables in the `.env` file, then:
```bash
docker compose up -d
```

View File

@@ -48,22 +48,12 @@ Copy/paste the `.env.example` in the `.env` file:
| GITHUB_ID | `123465` | The ID of the GitHub OAuth client. |
| GITHUB_SECRET | `123465` | The secret of the GitHub OAuth client. |
| RESEND_API_KEY | `123465` | The API key for Resend. |
| CLOUDFLARE_ZONE | `[{"zone_id":"abc465","zone_name":"example.com"}]` | The zone info for Cloudflare. |
| NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME | `example.com,example2.com` | The zone name for Cloudflare. |
| CLOUDFLARE_API_KEY | `123465` | The API key for Cloudflare. |
| CLOUDFLARE_EMAIL | `123465` | The email for Cloudflare. |
| NEXT_PUBLIC_OPEN_SIGNUP | `1` | Open signup. |
| SCREENSHOTONE_BASE_URL | `https://api.example.com` | pending |
| GITHUB_TOKEN | `ghp_sscsfarwetqet` | https://github.com/settings/tokens |
| NEXT_PUBLIC_SHORT_DOMAINS | `wr.do,uv.do` | The list of short domains. Separated by `,` |
| NEXT_PUBLIC_EMAIL_DOMAINS | `wr.do,uv.do` | The list of email domains. Separated by `,` |
> `NEXT_PUBLIC_SHORT_DOMAINS`、`NEXT_PUBLIC_EMAIL_DOMAINS` is used to limit the short domain name and email domain name.
- How to get `GOOGLE_CLIENT_ID`、`GITHUB_ID`, see [Authentification](/docs/developer/authentification).
- How to get `RESEND_API_KEY`, see [Email](/docs/developer/email).
- How to get `CLOUDFLARE_ZONE`、`CLOUDFLARE_API_KEY`、`CLOUDFLARE_EMAIL`、`NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME`, see [Cloudflare Configs](/docs/developer/cloudflare).
- How to get `DATABASE_URL`, see [Database](/docs/developer/database).
- How to active email worker, see [Email Worker](/docs/developer/cloudflare-email-worker).
For step by step installation, see [Quick Start](/docs/developer/quick-start).

View File

@@ -45,25 +45,23 @@ DATABASE_URL=
### Deploy Postgres
#### Manually install (Recommended)
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations/20240705091917_init/migration.sql),
copy the sql code to the database to initialize the database schema.
#### or
```bash
pnpm postinstall
pnpm db:push
```
#### Or manually init
Via [migration.sql](https://github.com/oiov/wr.do/blob/main/prisma/migrations),
copy the sql code to the database to initialize the database schema.
### Add the AUTH_SECRET Environment Variable
The `AUTH_SECRET` environment variable is used to encrypt tokens and email verification hashes(NextAuth.js).
You can generate one from https://generate-secret.vercel.app/32:
```js title=".env"
AUTH_SECRET=a3e686f39b2a878c6866e4604e6f1b1b
AUTH_SECRET=10000032bsfasfafk4lkkfsa
```
## 2. Configure Authentication Service
@@ -136,40 +134,7 @@ RESEND_API_KEY = re_your_resend_api_key;
</Steps>
## 3. Cloudflare Configs
Before you start, you must have a Cloudflare account and be hosted on Cloudflare.
### Add the CLOUDFLARE_ZONE Environment Variable
A JSON array of objects, each containing a zone_id and zone_name for your Cloudflare zones. The zone_id is the unique identifier for a domain, and the zone_name is the domain name (e.g., example.com).
> Follow [this way](https://dash.cloudflare.com/Your_Acount_Id/wr.do), and scroll down to `Zone ID`.
### Add the CLOUDFLARE_API_KEY Environment Variable
This is the API key that you use to authenticate requests to the Cloudflare API. You can generate or find your API key in the Cloudflare dashboard under the `profile` -> `api-tokens` section.
> Follow [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens), and scroll down to `API Token`, the `Global API Key` should be used.
### Add the CLOUDFLARE_EMAIL Environment Variable
This is the email address associated with your Cloudflare account. It is used for authentication alongside the API key.
### Add the NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME Environment Variable
A comma-separated list of domain names (e.g., `example.com,example2.com`) used for frontend display. These must correspond to the zone_name values in CLOUDFLARE_ZONE.
In this section, you can update these variables:
```js title=".env"
CLOUDFLARE_ZONE=[{"zone_id":"abc465","zone_name":"example.com"},{"zone_id":"abc465","zone_name":"example2.com"}]
NEXT_PUBLIC_CLOUDFLARE_ZONE_NAME=example.com,example2.com
CLOUDFLARE_API_KEY=1234567890abcdef1234567890abcdef
CLOUDFLARE_EMAIL=user@example.com
```
## 4. Email Worker Configs
## 3. Email Worker Configs
See detail in [Email Worker](/docs/developer/cloudflare-email-worker).
@@ -187,20 +152,14 @@ Via:
NEXT_PUBLIC_EMAIL_R2_DOMAIN=https://email-attachment.wr.do
```
## 5. Add the Bussiness Configs
## 4. Add the Bussiness Configs
```js title=".env"
# Allow anyone to sign up
NEXT_PUBLIC_OPEN_SIGNUP=1
# Short domains. Separated by `,`
NEXT_PUBLIC_SHORT_DOMAINS=wr.do,uv.do
# Email domains. Separated by `,`
NEXT_PUBLIC_EMAIL_DOMAINS=wr.do,uv.do
```
## 6. Add the SCREENSHOTONE_BASE_URL Environment Variable
## 5. Add the SCREENSHOTONE_BASE_URL Environment Variable
It's the base URL for the screenshotone API.
@@ -211,20 +170,44 @@ Deploy docs via [here](https://jasonraimondi.github.io/url-to-png/)
SCREENSHOTONE_BASE_URL=https://api.screenshotone.com
```
## 7. Add the GITHUB_TOKEN Environment Variable
## 6. Add the GITHUB_TOKEN Environment Variable
Via https://github.com/settings/tokens to get your token.
```js title=".env"
GITHUB_TOKEN=
```
## 8. Start the Dev Server
## 7. Start the Dev Server
```bash
pnpm dev
```
Via [http://localhost:3000](http://localhost:3000)
## 8. Setup System
#### Create the first account and Change the account's role to ADMIN
Follow the steps below:
- 1. Via [http://localhost:3000/login](http://localhost:3000/login), login with your account.
- 2. Via [http://localhost:3000/setup](http://localhost:3000/setup), change the account's role to ADMIN.
- 3. Then follow the **panel guide** to config the system and add the first domain.
![](/_static/docs/setup-1.png)
![](/_static/docs/setup-2.png)
<Callout type="info">
After change the account's role to ADMIN, then you can refresh the website and access http://localhost:3000/admin.
<strong>You must add at least one domain to start using short links, email or subdomain management features.</strong>
</Callout>
## 9. Deploy
See [Deploy Guide](/docs/developer/deploy).
## Q & A
@@ -238,17 +221,6 @@ https://dash.cloudflare.com/[account_id]/[zone_name]/ssl-tls/configuration
Change the `SSL/TLS Encryption` Mode to `Full` in the Cloudflare dashboard.
### How can I access the admin panel after first deployment?
You need to first register an account and log in,
and modify the `role` field of this account to `ADMIN` in the `users` table of the database.
Then, refresh the website and access http://localhost:3000/admin.
<Callout type="note">
Although it may not be convenient to do so, this is currently the fastest way to become an administrator.
In future versions, we will implement the function of automatically setting up administrators as soon as possible.
</Callout>
### How can I change the team plan quota?
Via team.ts:

View File

@@ -15,13 +15,20 @@ description: Welcome to the WR.DO documentation.
## Screenshots
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/light-preview.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/example_02.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/example_01.png"/>
<img className="rounded-xl border mt-4 md:rounded-lg shadow-md" src="/_static/images/example_03.png"/>
<table>
<tr>
<td><img src="https://wr.do/_static/images/light-preview.png" /></td>
<td><img src="https://wr.do/_static/images/example_02.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_01.png" /></td>
<td><img src="https://wr.do/_static/images/realtime-globe.png" /></td>
</tr>
<tr>
<td><img src="https://wr.do/_static/images/example_03.png" /></td>
<td><img src="https://wr.do/_static/images/domains.png" /></td>
</tr>
</table>
## Quick Start

View File

@@ -0,0 +1,53 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://postgres:postgres@postgres:5432/wrdo
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}
GITHUB_TOKEN: ${GITHUB_TOKEN}
SKIP_DB_CHECK: ${SKIP_DB_CHECK}
SKIP_DB_MIGRATION: ${SKIP_DB_MIGRATION}
depends_on:
- postgres
networks:
- wrdo-network
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=wrdo
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- wrdo-network
restart: unless-stopped
volumes:
postgres-data:
name: wrdo-postgres-data
networks:
wrdo-network:
driver: bridge

33
docker-compose.yml Normal file
View File

@@ -0,0 +1,33 @@
services:
app:
image: ghcr.io/oiov/wr.do/wrdo:${TAG:-latest}
container_name: wrdo
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
AUTH_SECRET: ${AUTH_SECRET:-your-auth-secret}
AUTH_URL: ${AUTH_URL}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_OPEN_SIGNUP: ${NEXT_PUBLIC_OPEN_SIGNUP}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GITHUB_ID: ${GITHUB_ID}
GITHUB_SECRET: ${GITHUB_SECRET}
LinuxDo_CLIENT_ID: ${LinuxDo_CLIENT_ID}
LinuxDo_CLIENT_SECRET: ${LinuxDo_CLIENT_SECRET}
RESEND_API_KEY: ${RESEND_API_KEY}
NEXT_PUBLIC_EMAIL_R2_DOMAIN: ${NEXT_PUBLIC_EMAIL_R2_DOMAIN}
NEXT_PUBLIC_GOOGLE_ID: ${NEXT_PUBLIC_GOOGLE_ID}
SCREENSHOTONE_BASE_URL: ${SCREENSHOTONE_BASE_URL}
GITHUB_TOKEN: ${GITHUB_TOKEN}
SKIP_DB_CHECK: ${SKIP_DB_CHECK}
SKIP_DB_MIGRATION: ${SKIP_DB_MIGRATION}
networks:
- wrdo-network
restart: unless-stopped
networks:
wrdo-network:
driver: bridge

21
env.mjs
View File

@@ -6,28 +6,22 @@ export const env = createEnv({
// This is optional because it's only used in development.
// See https://next-auth.js.org/deployment.
NEXTAUTH_URL: z.string().url().optional(),
AUTH_SECRET: z.string().min(1),
AUTH_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
LinuxDo_CLIENT_ID: z.string().optional(),
LinuxDo_CLIENT_SECRET: z.string().optional(),
DATABASE_URL: z.string().min(1),
DATABASE_URL: z.string().optional(),
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_APP_URL: z.string().optional(),
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),
NEXT_PUBLIC_EMAIL_R2_DOMAIN: z.string().optional(),
},
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,

102
hooks/use-element-size.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { debounce } from "lodash-es";
interface ElementSize {
width: number;
height: number;
}
interface UseElementSizeOptions {
box?: "content-box" | "border-box" | "device-pixel-content-box";
}
export function useElementSize<T extends HTMLElement>(
initialSize: ElementSize = { width: 0, height: 0 },
options: UseElementSizeOptions = {},
): { ref: React.RefObject<T>; width: number; height: number } {
const { box = "content-box" } = options;
const [size, setSize] = useState<ElementSize>(initialSize);
const ref = useRef<T>(null);
// 检查是否为 SVG 元素
const isSVG = useCallback(
() => ref.current?.namespaceURI?.includes("svg"),
[],
);
// 更新尺寸的防抖函数
const updateSize = useCallback(
debounce((newSize: ElementSize) => {
setSize((prev) =>
prev.width === newSize.width && prev.height === newSize.height
? prev
: newSize,
);
}, 100),
[],
);
// 初始化尺寸
useEffect(() => {
if (typeof window === "undefined") return;
const element = ref.current;
if (element) {
updateSize({
width:
"offsetWidth" in element ? element.offsetWidth : initialSize.width,
height:
"offsetHeight" in element ? element.offsetHeight : initialSize.height,
});
}
}, [initialSize, updateSize]);
// 监听尺寸变化
useEffect(() => {
if (typeof window === "undefined" || !window.ResizeObserver) return;
const element = ref.current;
if (!element) {
updateSize({ width: initialSize.width, height: initialSize.height });
return;
}
const observer = new window.ResizeObserver(([entry]) => {
const boxSize =
box === "border-box"
? entry.borderBoxSize
: box === "content-box"
? entry.contentBoxSize
: entry.devicePixelContentBoxSize;
if (isSVG()) {
const rect = entry.target.getBoundingClientRect();
updateSize({ width: rect.width, height: rect.height });
} else if (boxSize) {
const formatBoxSize = Array.isArray(boxSize) ? boxSize : [boxSize];
const width = formatBoxSize.reduce(
(acc, { inlineSize }) => acc + inlineSize,
0,
);
const height = formatBoxSize.reduce(
(acc, { blockSize }) => acc + blockSize,
0,
);
updateSize({ width, height });
} else {
updateSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(element);
return () => {
observer.unobserve(element);
observer.disconnect();
updateSize.cancel();
};
}, [box, initialSize, isSVG, updateSize]);
return { ref, width: size.width, height: size.height };
}

34
lib/domainConfig.ts Normal file
View File

@@ -0,0 +1,34 @@
import { getAllDomains } 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.list.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
}

139
lib/dto/domains.ts Normal file
View File

@@ -0,0 +1,139 @@
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;
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(page = 1, size = 10, target: string = "") {
try {
let option: any;
if (target) {
option = {
domain_name: {
contains: target,
},
};
}
const [total, list] = await prisma.$transaction([
prisma.domain.count({
where: option,
}),
prisma.domain.findMany({
where: option,
skip: (page - 1) * size,
take: size,
orderBy: {
updatedAt: "desc",
},
}),
]);
return { list, total };
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function getDomainsByFeature(
feature: string,
admin: boolean = false,
) {
try {
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
domain_name: true,
enable_short_link: admin,
enable_email: admin,
enable_dns: admin,
cf_zone_id: admin,
cf_api_key: admin,
cf_email: admin,
},
});
return domains;
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function getDomainsByFeatureClient(feature: string) {
try {
const domains = await prisma.domain.findMany({
where: { [feature]: true },
select: {
domain_name: true,
},
});
return domains;
} catch (error) {
throw new Error(`Failed to fetch domain config: ${error.message}`);
}
}
export async function createDomain(data: DomainConfig) {
try {
const createdDomain = await prisma.domain.create({ data });
return createdDomain;
} catch (error) {
throw new Error(`Failed to create domain: ${error.message}`);
}
}
export async function updateDomain(id: string, data) {
try {
const updatedDomain = await prisma.domain.update({
where: { id },
data: {
...data,
updatedAt: new Date(),
},
});
return updatedDomain;
} catch (error) {
throw new Error(`Failed to update domain: ${error.message}`);
}
}
export async function deleteDomain(domain_name: string) {
try {
const deletedDomain = await prisma.domain.delete({
where: { domain_name },
});
return deletedDomain;
} catch (error) {
throw new Error(`Failed to delete domain`);
}
}

View File

@@ -85,6 +85,17 @@ export async function getAllUsersCount() {
}
}
export async function setFirstUserAsAdmin(userId: string) {
try {
return await prisma.user.update({
where: { id: userId },
data: { role: UserRole.ADMIN },
});
} catch (error) {
return null;
}
}
export async function getAllUsersActiveApiKeyCount() {
try {
return await prisma.user.count({ where: { apiKey: { not: null } } });

View File

@@ -2,7 +2,7 @@ import { Resend } from "resend";
import { env } from "@/env.mjs";
export const resend = new Resend(env.RESEND_API_KEY);
export const resend = new Resend(env.RESEND_API_KEY || "re_key");
export function getVerificationEmailHtml({
url,

View File

@@ -312,3 +312,13 @@ export const DATE_DIMENSION_ENUMS = [
{ value: "365d", label: "Last 1 Year", key: 365 },
{ value: "All", label: "All the time", key: 1000 },
] as const;
export const DAILY_DIMENSION_ENUMS = [
{ value: "5min", label: "Last 5 Minutes", key: 5 },
{ value: "10min", label: "Last 10 Minutes", key: 10 },
{ value: "30min", label: "Last 30 Minutes", key: 30 },
{ value: "1h", label: "Last 1 Hour", key: 60 },
{ value: "6h", label: "Last 6 Hours", key: 360 },
{ value: "12h", label: "Last 12 Hours", key: 720 },
{ value: "24h", label: "Last 24 Hours", key: 1440 },
] as const;

150
lib/geo.ts Normal file
View File

@@ -0,0 +1,150 @@
import { userAgent } from "next/server";
import { Geo, geolocation, ipAddress } from "@vercel/functions";
import { NextAuthRequest } from "next-auth/lib";
import UAParser from "ua-parser-js";
interface GeoLocation extends Geo {
ip?: string;
}
const isVercel = process.env.VERCEL;
export async function getGeolocation(
req: NextAuthRequest,
ip: string,
): Promise<GeoLocation | null> {
// console.log("[Runtime Env]", isVercel ? "Vercel" : "Other");
if (isVercel) {
return geolocation(req);
} else {
return await getClientGeolocationWithIpApi(ip);
}
}
export function getUserAgent(req: NextAuthRequest) {
if (isVercel) {
return userAgent(req);
} else {
const headers = req.headers;
const userAgent = headers.get("user-agent") || "";
const parser = new UAParser(userAgent);
return {
browser: parser.getBrowser(),
device: parser.getDevice(),
os: parser.getOS(),
engine: parser.getEngine(),
cpu: parser.getCPU(),
isBot: false,
};
}
}
export async function getClientGeolocation(
req,
ip,
): Promise<GeoLocation | null> {
// const new_headers = new Headers();
// new_headers.set("X-Forwarded-For", ip);
// new_headers.set("User-Agent", req.headers.get("user-agent") || "");
const response = await fetch(`https://ip.wr.do/api?ip=${ip}`);
if (!response.ok) return null;
return await response.json();
}
export async function getClientGeolocationWithIpApi(ip: string) {
const response = await fetch(`http://ip-api.com/json/${ip}`);
if (!response.ok) return null;
const res = await response.json();
// {
// "query": "154.64.226.29",
// "status": "success",
// "continent": "North America",
// "continentCode": "NA",
// "country": "United States",
// "countryCode": "US",
// "region": "CA",
// "regionName": "California",
// "city": "Los Angeles",
// "district": "",
// "zip": "90009",
// "lat": 34.0549,
// "lon": -118.243,
// "timezone": "America/Los_Angeles",
// "offset": -25200,
// "currency": "USD",
// "isp": "Cogent Communications",
// "org": "NetLab Global",
// "as": "AS51847 Nearoute Limited",
// "asname": "NEAROUTE",
// "mobile": true,
// "proxy": true,
// "hosting": false
// }
return {
ip: res.query,
country: res.countryCode,
countryName: res.country,
region: res.region,
regionName: res.regionName,
city: res.city,
latitude: res.lat.toString(),
longitude: res.lon.toString(),
timezone: res.timezone,
};
}
export function extractRealIP(headers: Headers): string {
const ipHeaders = [
"X-Forwarded-For",
"X-Real-IP",
"CF-Connecting-IP",
"X-Client-IP",
"X-Cluster-Client-IP",
];
for (const header of ipHeaders) {
const value = headers.get(header);
if (value) {
const ip = value.split(",")[0].trim();
if (isValidIP(ip)) {
return ip;
}
}
}
return "::1";
}
function isValidIP(ip: string): boolean {
// IPv4 正则
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
// IPv6 正则(简化版)
const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
}
export async function getIpInfo(req) {
const headers = req.headers;
const ip = isVercel ? ipAddress(req) : extractRealIP(headers);
const ua = getUserAgent(req);
const geo = await getGeolocation(req, ip || "::1");
const userLanguage =
req.headers.get("accept-language")?.split(",")[0] || "en-US";
return {
referer: headers.get("referer") || "(None)",
ip: isVercel ? ip : geo?.ip,
city: geo?.city || "",
region: geo?.region || "",
country: geo?.country || "",
latitude: geo?.latitude || "",
longitude: geo?.longitude || "",
flag: geo?.flag,
lang: userLanguage,
device: ua.device.model || "Unknown",
browser: ua.browser.name || "Unknown",
};
}

View File

@@ -1,12 +1,9 @@
import crypto from "crypto";
import { Metadata } from "next";
import { geolocation } from "@vercel/functions";
import { clsx, type ClassValue } from "clsx";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import UAParser from "ua-parser-js";
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TIME_RANGES } from "./enums";
@@ -88,8 +85,16 @@ export function formatDate(input: string | number): string {
});
}
export function absoluteUrl(path: string) {
return `${env.NEXT_PUBLIC_APP_URL}${path}`;
export function formatTime(input: string | number): string {
const date = new Date(input);
const locale = navigator.language || "en-US";
return date.toLocaleTimeString(locale, {
// second: "numeric",
minute: "numeric",
hour: "numeric",
});
}
// Utils from precedent.dev
@@ -240,33 +245,6 @@ export function removeUrlSuffix(url: string): string {
return url.startsWith("http") ? url.split("//")[1] : url;
}
export function getIpInfo(req: Request) {
const geo = geolocation(req);
const ua = req.headers.get("user-agent") || "";
const parser = new UAParser();
parser.setUA(ua);
const browser = parser.getBrowser();
const device = parser.getDevice();
const referer = req.headers.get("referer") || "(None)";
const ip = req.headers.get("X-Forwarded-For") || "127.0.0.1";
const userLanguage =
req.headers.get("accept-language")?.split(",")[0] || "en-US";
return {
referer,
ip,
city: geo?.city || "",
region: geo?.region || "",
country: geo?.country || "",
latitude: geo?.latitude || "",
longitude: geo?.longitude || "",
flag: geo?.flag,
lang: userLanguage,
device: device.model || "Unknown",
browser: browser.name || "Unknown",
};
}
export function toCamelCase(str: string) {
return str
.split("-")
@@ -388,25 +366,3 @@ export function extractHost(url: string): string {
const match = url.match(regex);
return match ? match[1] : "";
}
// 解析 CLOUDFLARE_ZONE 环境变量并返回结构化的域名配置
export function parseZones(raw: string) {
let zones;
try {
zones = JSON.parse(raw);
} catch (error) {
return [];
}
if (!Array.isArray(zones)) {
return [];
}
const parsedZones = zones.map((zone) => {
const { zone_id, zone_name } = zone;
return { zone_id, zone_name };
});
return parsedZones;
}

17
lib/validations/domain.ts Normal file
View File

@@ -0,0 +1,17 @@
import * as z from "zod";
export const createDomainSchema = z.object({
id: z.string().optional(),
domain_name: z.string().min(2),
enable_short_link: z.boolean(),
enable_email: z.boolean(),
enable_dns: z.boolean(),
cf_zone_id: z.string().optional(),
cf_api_key: z.string().optional(),
cf_email: z.string().optional(),
cf_api_key_encrypted: z.boolean().default(false),
max_short_links: z.number().optional(),
max_email_forwards: z.number().optional(),
max_dns_records: z.number().optional(),
active: z.boolean().default(true),
});

View File

@@ -1,15 +1,17 @@
import { NextResponse } from "next/server";
import { geolocation } from "@vercel/functions";
import { ipAddress } from "@vercel/functions";
import { auth } from "auth";
import { NextAuthRequest } from "next-auth/lib";
import UAParser from "ua-parser-js";
import { siteConfig } from "./config/site";
import { extractRealIP, getGeolocation, getUserAgent } from "./lib/geo";
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
const isVercel = process.env.VERCEL;
const redirectMap = {
"Missing[0000]": "/docs/short-urls#missing-links",
"Expired[0001]": "/docs/short-urls#expired-links",
@@ -19,7 +21,6 @@ const redirectMap = {
"IncorrectPassword[0005]": "/password-prompt?error=1&slug=",
};
// 提取短链接处理逻辑
async function handleShortUrl(req: NextAuthRequest) {
if (!req.url.includes("/s/")) return NextResponse.next();
@@ -27,9 +28,11 @@ async function handleShortUrl(req: NextAuthRequest) {
if (!slug)
return NextResponse.redirect(`${siteConfig.url}/docs/short-urls`, 302);
const geo = geolocation(req);
const headers = req.headers;
const { browser, device } = parseUserAgent(headers.get("user-agent") || "");
const ip = isVercel ? ipAddress(req) : extractRealIP(headers);
const ua = getUserAgent(req);
const geo = await getGeolocation(req, ip || "::1");
const url = new URL(req.url);
const password = url.searchParams.get("password") || "";
@@ -37,19 +40,25 @@ async function handleShortUrl(req: NextAuthRequest) {
const trackingData = {
slug,
referer: headers.get("referer") || "(None)",
ip: headers.get("X-Forwarded-For"),
ip,
city: geo?.city,
region: geo?.region,
country: geo?.country,
latitude: geo?.latitude,
longitude: geo?.longitude,
flag: geo?.flag,
lang: headers.get("accept-language")?.split(",")[0],
device: device.model || "Unknown",
browser: browser.name || "Unknown",
lang: headers.get("accept-language")?.split(",")[0] || "Unknown",
device: ua.device.model || "Unknown",
browser: ua.browser.name || "Unknown",
engine: ua.engine.name || "",
os: ua.os.name || "",
cpu: ua.cpu.architecture || "",
isBot: ua.isBot,
password,
};
// console.log("Tracking data:", trackingData, siteConfig.url);
const res = await fetch(`${siteConfig.url}/api/s`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -90,22 +99,11 @@ async function handleShortUrl(req: NextAuthRequest) {
return NextResponse.redirect(target, 302);
}
// 提取 slug
function extractSlug(url: string): string | null {
const match = url.match(/([^/?]+)(?:\?.*)?$/);
return match ? match[1] : null;
}
// 解析用户代理
const parser = new UAParser();
function parseUserAgent(ua: string) {
parser.setUA(ua);
return {
browser: parser.getBrowser(),
device: parser.getDevice(),
};
}
export default auth(async (req) => {
try {
return await handleShortUrl(req);

View File

@@ -6,6 +6,7 @@ import("./env.mjs");
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: "standalone",
images: {
remotePatterns: [
{

View File

@@ -1,6 +1,6 @@
{
"name": "wr.do",
"version": "0.5.1",
"version": "0.6.2",
"author": {
"name": "oiov",
"url": "https://github.com/oiov"
@@ -11,16 +11,23 @@
"postbuild": "next-sitemap",
"turbo": "next dev --turbo",
"start": "next start",
"start-docker": "npm-run-all check-db start-server",
"start-server": "node server.js",
"lint": "next lint",
"preview": "next build && next start",
"postinstall": "prisma generate",
"db:push": "npx prisma migrate deploy",
"email": "email dev --dir emails --port 3333",
"remove-content": "node ./setup.mjs"
"remove-content": "node ./setup.mjs",
"check-db": "node scripts/check-db.js"
},
"prisma": {
"schema": "./prisma/schema.prisma"
},
"dependencies": {
"@auth/prisma-adapter": "^2.4.1",
"@hookform/resolvers": "^3.9.0",
"@mantine/hooks": "^8.0.1",
"@prisma/client": "^5.17.0",
"@radix-ui/react-accessible-icon": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.0",
@@ -55,6 +62,10 @@
"@react-email/html": "0.0.8",
"@scaleway/random-name": "^5.1.1",
"@t3-oss/env-nextjs": "^0.11.0",
"@types/d3-scale": "^4.0.9",
"@types/d3-scale-chromatic": "^3.1.0",
"@types/lodash-es": "^4.17.12",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.16.1",
"@uiw/react-json-view": "2.0.0-alpha.26",
"@unovis/react": "^1.4.3",
@@ -63,15 +74,21 @@
"@vercel/functions": "^1.4.0",
"@vercel/og": "^0.6.2",
"cheerio": "1.0.0-rc.12",
"chalk": "^4.1.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"concurrently": "^8.2.2",
"contentlayer2": "^0.5.0",
"crypto": "^1.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"date-fns": "^3.6.0",
"dotenv": "^10.0.0",
"framer-motion": "^12.5.0",
"globe.gl": "^2.41.4",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lucide-react": "^0.414.0",
"lucide-static": "^0.460.0",
"minimist": "^1.2.8",
@@ -84,14 +101,17 @@
"next-themes": "^0.3.0",
"next-view-transitions": "^0.3.0",
"nodemailer": "^6.9.14",
"npm-run-all": "^4.1.5",
"peerjs": "^1.5.4",
"prop-types": "^15.8.1",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-country-flag": "^3.1.0",
"react-countup": "^6.5.3",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-email": "2.1.5",
"react-globe.gl": "^2.33.2",
"react-hook-form": "^7.52.1",
"react-quill": "^2.0.0",
"react-textarea-autosize": "^8.5.3",
@@ -101,8 +121,10 @@
"shiki": "^1.11.0",
"sonner": "^1.5.0",
"swr": "^2.2.5",
"semver": "^7.5.4",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.176.0",
"turndown": "^7.2.0",
"ua-parser-js": "^1.0.38",
"vaul": "^0.9.1",

686
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -274,7 +274,7 @@ CREATE TABLE "user_send_emails"
"html" TEXT DEFAULT '',
"replyTo" TEXT DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "user_send_emails_userId_idx" ON "user_send_emails" ("userId");

View File

@@ -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
);

View File

@@ -247,3 +247,24 @@ model UserSendEmail {
@@index([createdAt])
@@map("user_send_emails")
}
model Domain {
id String @id @default(uuid())
domain_name String @unique
enable_short_link Boolean @default(false)
enable_email Boolean @default(false)
enable_dns Boolean @default(false)
cf_zone_id String?
cf_api_key String?
cf_email String?
cf_api_key_encrypted Boolean
max_short_links Int?
max_email_forwards Int?
max_dns_records Int?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@map("domains")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

1
public/colos.json Normal file

File diff suppressed because one or more lines are too long

26017
public/countries.geojson Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -3,7 +3,7 @@
"short_name": "WR.DO",
"description": "Shorten links with analytics, manage emails and control subdomains—all on one platform.",
"appid": "com.wr.do",
"versionName": "0.5.1",
"versionName": "0.6.2",
"versionCode": "1",
"start_url": "/",
"orientation": "portrait",

View File

@@ -1,39 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-10T03:08:41.904Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-10T03:08:41.905Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/robots.txt</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/pricing</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/privacy</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/terms</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/authentification</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/cloudflare-email-worker</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/components</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/config-files</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/database</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/email</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/installation</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/markdown-files</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/developer/quick-start</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/dns-records</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/emails</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/cloudflare</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/other</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/vercel</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/examples/zeabur</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/icon</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/markdown</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/meta-info</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/qrcode</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/screenshot</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/open-api/text</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/plan</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/quick-start</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/short-urls</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/docs/wroom</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/chat</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/password-prompt</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/manifest.json</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://wr.do/opengraph-image.jpg</loc><lastmod>2025-05-26T09:40:46.922Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

File diff suppressed because one or more lines are too long

96
scripts/check-db.js Normal file
View File

@@ -0,0 +1,96 @@
/* eslint-disable no-console */
require("dotenv").config();
const { PrismaClient } = require("@prisma/client");
const chalk = require("chalk");
const { execSync } = require("child_process");
const semver = require("semver");
if (process.env.SKIP_DB_CHECK === "true") {
console.log("Skipping database check.");
process.exit(0);
}
function getDatabaseType(url = process.env.DATABASE_URL) {
const type = url && url.split(":")[0];
if (type === "postgres") {
return "postgresql";
}
return type;
}
const prisma = new PrismaClient();
function success(msg) {
console.log(chalk.greenBright(`${msg}`));
}
function error(msg) {
console.log(chalk.redBright(`${msg}`));
}
async function checkEnv() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not defined.");
} else {
success("DATABASE_URL is defined.");
}
}
async function checkConnection() {
try {
await prisma.$connect();
success("Database connection successful.");
} catch (e) {
throw new Error("Unable to connect to the database: " + e.message);
}
}
async function checkDatabaseVersion() {
const query = await prisma.$queryRaw`select version() as version`;
const version = semver.valid(semver.coerce(query[0].version));
const databaseType = getDatabaseType();
const minVersion = databaseType === "postgresql" ? "9.4.0" : "5.7.0";
if (semver.lt(version, minVersion)) {
throw new Error(
`Database version is not compatible. Please upgrade ${databaseType} version to ${minVersion} or greater`,
);
}
success("Database version check successful.");
}
async function applyMigration() {
if (process.env.SKIP_DB_MIGRATION === "false") {
console.log(execSync("prisma generate").toString());
console.log(execSync("prisma migrate deploy").toString());
success("Database is up to date.");
}
}
(async () => {
let err = false;
for (let fn of [
checkEnv,
checkConnection,
checkDatabaseVersion,
applyMigration,
]) {
try {
await fn();
} catch (e) {
error(e.message);
err = true;
} finally {
await prisma.$disconnect();
if (err) {
process.exit(1);
}
}
}
})();

View File

@@ -88,6 +88,16 @@
}
}
.globe-tooltip {
background: rgba(0, 0, 0, 0.8) !important;
color: white !important;
padding: 8px !important;
border-radius: 4px !important;
font-size: 12px !important;
z-index: 1000 !important;
pointer-events: none !important;
}
.text-gradient_indigo-purple {
background: linear-gradient(90deg, #6366f1 0%, rgb(168 85 247 / 0.8) 100%);
-webkit-background-clip: text;

Some files were not shown because too many files have changed in this diff Show More